audioFilters)
{
super(aliasList);
- mAudioFilterEnable = audioFilterEnable;
+ mAudioFilters = audioFilters;
}
@Override
@@ -128,9 +101,13 @@ public class AudioModule extends AbstractAudioModule implements ISquelchStateLis
{
if(mSquelchState == SquelchState.UNSQUELCH)
{
- if(mAudioFilterEnable)
+ // apply audio filters if any (i.e. deemphasis, high pass, etc.)
+ if(!mAudioFilters.isEmpty())
{
- audioBuffer = mHighPassFilter.filter(audioBuffer);
+ for(AbstractAudioFilter filter : mAudioFilters)
+ {
+ audioBuffer = filter.filter(audioBuffer);
+ }
}
addAudio(audioBuffer);
diff --git a/src/main/java/io/github/dsheirer/audio/HighPassAudioFilter.java b/src/main/java/io/github/dsheirer/audio/HighPassAudioFilter.java
new file mode 100644
index 00000000..b8962458
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/audio/HighPassAudioFilter.java
@@ -0,0 +1,50 @@
+package io.github.dsheirer.audio;
+
+import io.github.dsheirer.dsp.filter.FilterFactory;
+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.fir.remez.RemezFIRFilterDesigner;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HighPassAudioFilter extends AbstractAudioFilter
+{
+ private static final Logger mLog = LoggerFactory.getLogger(HighPassAudioFilter.class);
+ private static float[] sHighPassFilterCoefficients;
+
+ static
+ {
+ FIRFilterSpecification specification = FIRFilterSpecification.highPassBuilder()
+ .sampleRate(8000)
+ .stopBandCutoff(200)
+ .stopBandAmplitude(0.0)
+ .stopBandRipple(0.025)
+ .passBandStart(300)
+ .passBandAmplitude(1.0)
+ .passBandRipple(0.01)
+ .build();
+ try
+ {
+ RemezFIRFilterDesigner designer = new RemezFIRFilterDesigner(specification);
+
+ if(designer.isValid())
+ {
+ sHighPassFilterCoefficients = designer.getImpulseResponse();
+ }
+ }
+ catch(FilterDesignException fde)
+ {
+ mLog.error("Filter design error", fde);
+ }
+ }
+
+ private final IRealFilter mHighPassFilter = FilterFactory.getRealFilter(sHighPassFilterCoefficients);
+
+ @Override
+ public float[] filter(float[] audio)
+ {
+ audio = mHighPassFilter.filter(audio);
+ return audio;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/dsp/filter/GoertzelFilter.java b/src/main/java/io/github/dsheirer/dsp/filter/GoertzelFilter.java
index b0ce0805..717b72f2 100644
--- a/src/main/java/io/github/dsheirer/dsp/filter/GoertzelFilter.java
+++ b/src/main/java/io/github/dsheirer/dsp/filter/GoertzelFilter.java
@@ -24,7 +24,7 @@ import org.apache.commons.math3.util.FastMath;
/**
* Implements the Goertzel Filter as described in
* http://en.wikipedia.org/wiki/Goertzel_algorithm
- *
+ *
* Calculates the magnitude of a specific frequency component within a window of
* samples, using a Goertzel Filter (ie an optimized DFT).
*/
@@ -101,9 +101,10 @@ public class GoertzelFilter
//power = s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2 ;
double magnitude = (s_prev2 * s_prev2) + (s_prev * s_prev) - (mCoefficient * s_prev * s_prev2);
- int binZero = getBinZeroPower(samples);
-
- int power = (int)(20 * FastMath.log10(magnitude / binZero));
+ // Removed because bin(0) is likely to be (int) zero on audio samples
+ //int binZero = getBinZeroPower(samples);
+ //int power = (int)(20 * FastMath.log10(magnitude / binZero));
+ int power = (int)(20 * FastMath.log10(magnitude));
return power;
}
@@ -119,7 +120,6 @@ public class GoertzelFilter
{
retVal += sample;
}
-
return retVal;
}
diff --git a/src/main/java/io/github/dsheirer/dsp/filter/iir/DeemphasisFilter.java b/src/main/java/io/github/dsheirer/dsp/filter/iir/DeemphasisFilter.java
index 0de85846..3c8226d5 100644
--- a/src/main/java/io/github/dsheirer/dsp/filter/iir/DeemphasisFilter.java
+++ b/src/main/java/io/github/dsheirer/dsp/filter/iir/DeemphasisFilter.java
@@ -15,15 +15,22 @@
******************************************************************************/
package io.github.dsheirer.dsp.filter.iir;
+import io.github.dsheirer.audio.AbstractAudioFilter;
import org.apache.commons.math3.util.FastMath;
-public class DeemphasisFilter
+public class DeemphasisFilter extends AbstractAudioFilter
{
private static final float MAX_SAMPLE_VALUE = 0.95f;
private float mAlpha;
private float mGain;
private float mPrevious = 0.0f;
-
+
+ /**
+ * Create an instance
+ * @param sampleRate - usually 8000 Hz
+ * @param cutoff - cutoff frequency
+ * @param gain - gain (linear)
+ */
public DeemphasisFilter( float sampleRate, float cutoff, float gain )
{
mAlpha = (float)FastMath.exp( -2.0 * FastMath.PI * cutoff * ( 1.0 / sampleRate ) );
@@ -45,7 +52,7 @@ public class DeemphasisFilter
}
else return FastMath.max(value, -MAX_SAMPLE_VALUE);
}
-
+ @Override
public float[] filter( float[] samples )
{
for( int x = 0; x < samples.length; x++ )
diff --git a/src/main/java/io/github/dsheirer/dsp/gain/AudioGainAndDcFilter.java b/src/main/java/io/github/dsheirer/dsp/gain/AudioGainAndDcFilter.java
index 149f5356..224aadfc 100644
--- a/src/main/java/io/github/dsheirer/dsp/gain/AudioGainAndDcFilter.java
+++ b/src/main/java/io/github/dsheirer/dsp/gain/AudioGainAndDcFilter.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -47,6 +47,7 @@ public class AudioGainAndDcFilter
private float mObjectiveGain;
private float mObjectiveAmplitude;
private float mMaxObservedAmplitude;
+ private float mPercentDecayRate = 10f;
/**
* Constructs an instance
@@ -82,8 +83,9 @@ public class AudioGainAndDcFilter
float currentAmplitude;
float dcAccumulator = 0.0f;
- //Decay the max observed value by 10% each buffer so that an initial spike doesn't carry across all buffers
- mMaxObservedAmplitude *= 0.9f;
+ //Decay the max observed value by a specified percentage each buffer so that an initial
+ // spike doesn't carry across all buffers
+ mMaxObservedAmplitude *= ((100 - mPercentDecayRate )/ 100);
for(float sample: samples)
{
@@ -133,7 +135,17 @@ public class AudioGainAndDcFilter
}
mCurrentGain = gain;
+ //mLog.debug("Gain: {}", gain);
mObjectiveGain = objective;
return processed;
}
+
+ /**
+ * Overrides the default decay rate of 10 %.
+ * @param newRate specified as a percentage (i.e. 2 equals 2%)
+ */
+ public void setDecayRate(float newRate)
+ {
+ mPercentDecayRate = newRate;
+ }
}
diff --git a/src/main/java/io/github/dsheirer/gui/playlist/alias/identifier/DcsEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/alias/identifier/DcsEditor.java
index 02331f52..48a0e10a 100644
--- a/src/main/java/io/github/dsheirer/gui/playlist/alias/identifier/DcsEditor.java
+++ b/src/main/java/io/github/dsheirer/gui/playlist/alias/identifier/DcsEditor.java
@@ -19,7 +19,7 @@
package io.github.dsheirer.gui.playlist.alias.identifier;
import io.github.dsheirer.alias.id.dcs.Dcs;
-import io.github.dsheirer.module.decode.dcs.DCSCode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
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 4149ac85..4af3f85d 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-2025 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -28,7 +28,11 @@ import io.github.dsheirer.gui.playlist.source.SourceConfigurationEditor;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.config.AuxDecodeConfiguration;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
+import io.github.dsheirer.module.decode.squelch.SquelchDecoderConfig;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSCode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM;
+import io.github.dsheirer.module.decode.nbfm.DeemphasisMode;
import io.github.dsheirer.module.log.EventLogType;
import io.github.dsheirer.module.log.config.EventLogConfiguration;
import io.github.dsheirer.playlist.PlaylistManager;
@@ -46,13 +50,8 @@ import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
-import javafx.scene.control.Label;
-import javafx.scene.control.TextField;
-import javafx.scene.control.TextFormatter;
-import javafx.scene.control.TitledPane;
-import javafx.scene.control.Toggle;
-import javafx.scene.control.ToggleButton;
-import javafx.scene.control.Tooltip;
+import javafx.scene.control.*;
+import javafx.scene.control.skin.ComboBoxListViewSkin;
import javafx.scene.layout.GridPane;
import javafx.scene.text.TextAlignment;
import org.controlsfx.control.SegmentedButton;
@@ -70,6 +69,7 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
private TitledPane mSourcePane;
private TextField mTalkgroupField;
private ToggleSwitch mAudioFilterEnable;
+ private ToggleSwitch mALCEnable;
private TextFormatter mTalkgroupTextFormatter;
private ToggleSwitch mBasebandRecordSwitch;
private SegmentedButton mBandwidthButton;
@@ -81,6 +81,11 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
private final IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);
private final HexFormatter mHexFormatter = new HexFormatter(1, 65535);
+ private ComboBox mDeemphasisCombo;
+ private ComboBox mSquelchTypeCombo;
+ private ComboBox mCtcssCodeCombo;
+ private ComboBox mDcsCodeCombo;
+
/**
* Constructs an instance
* @param playlistManager for playlists
@@ -128,6 +133,7 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
gridPane.setHgap(10);
gridPane.setVgap(10);
+ // channel bandwidth
Label bandwidthLabel = new Label("Channel Bandwidth");
GridPane.setHalignment(bandwidthLabel, HPos.RIGHT);
GridPane.setConstraints(bandwidthLabel, 0, 0);
@@ -136,16 +142,86 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
GridPane.setConstraints(getBandwidthButton(), 1, 0);
gridPane.getChildren().add(getBandwidthButton());
+ //De-emphasis
+ Label deemphasisLabel = new Label("De-emphasis");
+ GridPane.setHalignment(deemphasisLabel, HPos.RIGHT);
+ GridPane.setConstraints(deemphasisLabel, 0, 1);
+ gridPane.getChildren().add(deemphasisLabel);
+ GridPane.setConstraints(getDeemphasisCombo(), 1, 1, 2, 1);
+ gridPane.getChildren().add(getDeemphasisCombo());
+
+ // High pass audio filter
+ // Note: normally the label and its toggle switch are one cell. They were split to make the next row
+ // look better. The label is manually created and placed here. Might want to create a new grid pane instead.
+ Label HPFLabel = new Label("High-pass Audio Filter (300 Hz)");
+ GridPane.setHalignment(HPFLabel, HPos.RIGHT);
+ GridPane.setConstraints(HPFLabel, 2, 1);
+ gridPane.getChildren().add(HPFLabel);
+ GridPane.setConstraints(getAudioFilterEnable(), 3, 1);
+ gridPane.getChildren().add(getAudioFilterEnable());
+
+ // ALC (Automatic Level Control)
+ GridPane.setConstraints(getALCEnable(), 4, 1);
+ gridPane.getChildren().add(getALCEnable());
+
+ /*
+ * The intention here is to have talkgroup and squelch decoder settings on a single line at the bottom
+ * of the pane. In the future a button can be added to add additional talkgroups and filter lines to
+ * allow for multiple decoders on a single NBFM channel. For the time being, a user can "clone" the
+ * channel and setup another decoder at the expense of another channel processing thread.
+ */
+ // talkgroup
Label talkgroupLabel = new Label("Talkgroup To Assign");
GridPane.setHalignment(talkgroupLabel, HPos.RIGHT);
- GridPane.setConstraints(talkgroupLabel, 0, 1);
+ GridPane.setConstraints(talkgroupLabel, 0, 2);
gridPane.getChildren().add(talkgroupLabel);
- GridPane.setConstraints(getTalkgroupField(), 1, 1);
+ GridPane.setConstraints(getTalkgroupField(), 1, 2);
gridPane.getChildren().add(getTalkgroupField());
- GridPane.setConstraints(getAudioFilterEnable(), 2, 1);
- gridPane.getChildren().add(getAudioFilterEnable());
+ // Squelch Decoder type
+ Label typeLabel = new Label("Squelch Decoder Type");
+ GridPane.setHalignment(typeLabel, HPos.RIGHT);
+ GridPane.setConstraints(typeLabel, 2, 2);
+ gridPane.getChildren().add(typeLabel);
+
+ mSquelchTypeCombo = new ComboBox<>();
+ mSquelchTypeCombo.getItems().addAll(SquelchDecoderConfig.SquelchType.SQUELCH_TYPE);
+ mSquelchTypeCombo.setValue(SquelchDecoderConfig.SquelchType.NONE);
+ mSquelchTypeCombo.valueProperty().addListener((obs, ov, nv) -> {
+ updateSquelchCodeVisibility();
+ mCtcssCodeCombo.setValue(null);
+ mDcsCodeCombo.setValue(null);
+ // this hack solves the problem of the promptText going away after using the comboBox first time (Java 25):
+ mCtcssCodeCombo.setSkin(new ComboBoxListViewSkin<>(mCtcssCodeCombo));
+ mDcsCodeCombo.setSkin(new ComboBoxListViewSkin<>(mDcsCodeCombo));
+ modifiedProperty().set(true);
+ });
+ GridPane.setConstraints(mSquelchTypeCombo, 3, 2);
+ gridPane.getChildren().add(mSquelchTypeCombo);
+
+ // CTCSS code selector
+ mCtcssCodeCombo = new ComboBox<>();
+ mCtcssCodeCombo.getItems().addAll(CTCSSCode.STANDARD_CODES); // excludes unknown/dummy codes
+ mCtcssCodeCombo.setValue(null);
+ mCtcssCodeCombo.setPromptText("Select CTCSS tone");
+ mCtcssCodeCombo.setPrefWidth(200);
+ mCtcssCodeCombo.setVisible(false);
+ mCtcssCodeCombo.valueProperty().addListener((obs, ov, nv) -> modifiedProperty().set(true));
+ GridPane.setConstraints(mCtcssCodeCombo, 4, 2);
+ gridPane.getChildren().add(mCtcssCodeCombo);
+
+ // DCS code selector
+ mDcsCodeCombo = new ComboBox<>();
+ mDcsCodeCombo.getItems().addAll(DCSCode.STANDARD_CODES);
+ mDcsCodeCombo.getItems().addAll(DCSCode.INVERTED_CODES);
+ mDcsCodeCombo.setValue(null);
+ mDcsCodeCombo.setPromptText("Select DCS code");
+ mDcsCodeCombo.setPrefWidth(200);
+ mDcsCodeCombo.setVisible(false);
+ mDcsCodeCombo.valueProperty().addListener((obs, ov, nv) -> modifiedProperty().set(true));
+ GridPane.setConstraints(mDcsCodeCombo, 4, 2);
+ gridPane.getChildren().add(mDcsCodeCombo);
mDecoderPane.setContent(gridPane);
@@ -163,6 +239,46 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
return mDecoderPane;
}
+ private ComboBox getDeemphasisCombo()
+ {
+ if(mDeemphasisCombo == null)
+ {
+ mDeemphasisCombo = new ComboBox<>();
+ mDeemphasisCombo.getItems().addAll(DeemphasisMode.values());
+ mDeemphasisCombo.setValue(DeemphasisMode.NBFM_300);
+ mDeemphasisCombo.setTooltip(new Tooltip("FM de-emphasis restores flat audio from pre-emphasized FM signal"));
+ mDeemphasisCombo.valueProperty().addListener((obs, ov, nv) -> modifiedProperty().set(true));
+ }
+ return mDeemphasisCombo;
+ }
+
+ private void updateSquelchCodeVisibility()
+ {
+ switch(mSquelchTypeCombo.getValue())
+ {
+ case SquelchDecoderConfig.SquelchType.NONE:
+ mCtcssCodeCombo.setVisible(false);
+ mDcsCodeCombo.setVisible(false);
+ break;
+ case SquelchDecoderConfig.SquelchType.CTCSS:
+ mCtcssCodeCombo.setVisible(true);
+ mDcsCodeCombo.setVisible(false);
+ break;
+ case SquelchDecoderConfig.SquelchType.DCS:
+ mCtcssCodeCombo.setVisible(false);
+ mDcsCodeCombo.setVisible(true);
+ break;
+ }
+ }
+
+ private void resetSquelchCodes()
+ {
+ mSquelchTypeCombo.setValue(SquelchDecoderConfig.SquelchType.NONE);
+ mCtcssCodeCombo.setValue(null);
+ mDcsCodeCombo.setValue(null);
+ updateSquelchCodeVisibility();
+ }
+
private TitledPane getEventLogPane()
{
if(mEventLogPane == null)
@@ -262,14 +378,31 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
{
if(mAudioFilterEnable == null)
{
- mAudioFilterEnable = new ToggleSwitch("High-Pass Audio Filter");
+ // removed label from toggle switch so that the label and the switch can be in different columns
+ // to make things look better on the next line. Perhaps make a new grid pane instead.
+ //mAudioFilterEnable = new ToggleSwitch("High-Pass Audio Filter (300 Hz)");
+ mAudioFilterEnable = new ToggleSwitch("");
mAudioFilterEnable.setTooltip(new Tooltip("High-pass filter to remove DC offset and sub-audible signalling"));
mAudioFilterEnable.selectedProperty().addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
}
return mAudioFilterEnable;
}
+ /**
+ * Toggle switch for enable/disable the Automatic Level Control (ALC) in the audio module.
+ * @return toggle switch.
+ */
+ private ToggleSwitch getALCEnable()
+ {
+ if(mALCEnable == null)
+ {
+ mALCEnable = new ToggleSwitch("Automatic Level Control");
+ mALCEnable.setTooltip(new Tooltip("Automatic Level Control (ALC). Tries to make the volume of each received transmission the same."));
+ mALCEnable.selectedProperty().addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
+ }
+ return mALCEnable;
+ }
private SegmentedButton getBandwidthButton()
{
if(mBandwidthButton == null)
@@ -327,6 +460,7 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
{
mTalkgroupField = new TextField();
mTalkgroupField.setTextFormatter(mTalkgroupTextFormatter);
+ mTalkgroupField.setPrefWidth(100);
}
return mTalkgroupField;
@@ -412,6 +546,46 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
updateTextFormatter(decodeConfigNBFM.getTalkgroup());
getAudioFilterEnable().setDisable(false);
getAudioFilterEnable().setSelected(decodeConfigNBFM.isAudioFilter());
+
+ getALCEnable().setDisable(false);
+ getALCEnable().setSelected(decodeConfigNBFM.isAudioALC());
+
+ getDeemphasisCombo().setValue(decodeConfigNBFM.getDeemphasis());
+
+ List savedSquelchDecoders = decodeConfigNBFM.getSquelchDecoders();
+ if(savedSquelchDecoders != null && !savedSquelchDecoders.isEmpty())
+ {
+
+ // At present time, only one decoder per channel is used
+ SquelchDecoderConfig filter = savedSquelchDecoders.get(0);
+ mSquelchTypeCombo.setValue(filter.getSquelchType());
+ updateSquelchCodeVisibility();
+ if(filter.getSquelchType() == SquelchDecoderConfig.SquelchType.NONE)
+ {
+ mSquelchTypeCombo.setValue(SquelchDecoderConfig.SquelchType.NONE);
+ resetSquelchCodes();
+ }
+ if(filter.getSquelchType() == SquelchDecoderConfig.SquelchType.CTCSS)
+ {
+ CTCSSCode code = filter.getCTCSSCode();
+ if(code != null && code != CTCSSCode.UNKNOWNH && code != CTCSSCode.UNKNOWNL)
+ {
+ mCtcssCodeCombo.setValue(code);
+ }
+ }
+ if(filter.getSquelchType() == SquelchDecoderConfig.SquelchType.DCS)
+ {
+ DCSCode code = filter.getDCSCode();
+ if(code != null)
+ {
+ mDcsCodeCombo.setValue(code);
+ }
+ }
+ }
+ else
+ {
+ resetSquelchCodes();
+ }
}
else
{
@@ -426,6 +600,11 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
getTalkgroupField().setDisable(true);
getAudioFilterEnable().setDisable(true);
getAudioFilterEnable().setSelected(false);
+ getALCEnable().setDisable(true);
+ getALCEnable().setSelected(false);
+
+ getDeemphasisCombo().setValue(DeemphasisMode.NONE);
+ resetSquelchCodes();
}
}
@@ -461,6 +640,33 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor
config.setTalkgroup(talkgroup);
config.setAudioFilter(getAudioFilterEnable().isSelected());
+ config.setAudioALC(getALCEnable().isSelected());
+
+ config.setDeemphasis(getDeemphasisCombo().getValue());
+
+ List squelchDecoders = new ArrayList<>();
+ SquelchDecoderConfig.SquelchType selectedType = mSquelchTypeCombo.getValue();
+ if(selectedType == SquelchDecoderConfig.SquelchType.CTCSS)
+ {
+ CTCSSCode code = mCtcssCodeCombo.getValue();
+ if(code != null)
+ {
+ squelchDecoders.add(new SquelchDecoderConfig(selectedType, code.name()));
+ }
+ }
+ if(selectedType == SquelchDecoderConfig.SquelchType.DCS)
+ {
+ DCSCode code = mDcsCodeCombo.getValue();
+ if(code != null)
+ {
+ squelchDecoders.add(new SquelchDecoderConfig(selectedType, code.name()));
+ }
+ }
+ if(selectedType == SquelchDecoderConfig.SquelchType.NONE)
+ {
+ squelchDecoders.add((new SquelchDecoderConfig(selectedType, null)));
+ }
+ config.setSquelchDecoders(squelchDecoders);
getItem().setDecodeConfiguration(config);
}
diff --git a/src/main/java/io/github/dsheirer/identifier/ctcss/CTCSSIdentifier.java b/src/main/java/io/github/dsheirer/identifier/ctcss/CTCSSIdentifier.java
new file mode 100644
index 00000000..23619bfe
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/identifier/ctcss/CTCSSIdentifier.java
@@ -0,0 +1,48 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.identifier.ctcss;
+
+import io.github.dsheirer.identifier.Form;
+import io.github.dsheirer.identifier.Identifier;
+import io.github.dsheirer.identifier.IdentifierClass;
+import io.github.dsheirer.identifier.Role;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSCode;
+import io.github.dsheirer.protocol.Protocol;
+
+/**
+ * Continuous Tone Coded Squelch code identifier. This is decoded from the transmitted signal and represents the FROM role.
+ */
+public class CTCSSIdentifier extends Identifier
+{
+ /**
+ * Constructs an instance.
+ * @param code detected.
+ */
+ public CTCSSIdentifier(CTCSSCode code)
+ {
+ super(code, IdentifierClass.USER, Form.TONE, Role.FROM);
+ }
+
+ @Override
+ public Protocol getProtocol()
+ {
+ return Protocol.CTCSS;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/identifier/dcs/DCSIdentifier.java b/src/main/java/io/github/dsheirer/identifier/dcs/DCSIdentifier.java
index 6c1bca4a..0401f0b7 100644
--- a/src/main/java/io/github/dsheirer/identifier/dcs/DCSIdentifier.java
+++ b/src/main/java/io/github/dsheirer/identifier/dcs/DCSIdentifier.java
@@ -23,7 +23,7 @@ import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.Role;
-import io.github.dsheirer.module.decode.dcs.DCSCode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
import io.github.dsheirer.protocol.Protocol;
/**
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 8f7333dc..0d242bfa 100644
--- a/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java
+++ b/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java
@@ -21,14 +21,14 @@ package io.github.dsheirer.module.decode;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.alias.action.AliasActionManager;
-import io.github.dsheirer.audio.AbstractAudioModule;
-import io.github.dsheirer.audio.AudioModule;
+import io.github.dsheirer.audio.*;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.map.ChannelMap;
import io.github.dsheirer.controller.channel.map.ChannelMapModel;
+import io.github.dsheirer.dsp.filter.iir.DeemphasisFilter;
import io.github.dsheirer.filter.AllPassFilter;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.filter.IFilter;
@@ -41,9 +41,10 @@ import io.github.dsheirer.module.decode.am.AMDecoderState;
import io.github.dsheirer.module.decode.am.DecodeConfigAM;
import io.github.dsheirer.module.decode.config.AuxDecodeConfiguration;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
-import io.github.dsheirer.module.decode.dcs.DCSDecoder;
-import io.github.dsheirer.module.decode.dcs.DCSDecoderState;
-import io.github.dsheirer.module.decode.dcs.DCSMessageFilter;
+import io.github.dsheirer.module.decode.nbfm.DeemphasisMode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSDecoder;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSDecoderState;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSMessageFilter;
import io.github.dsheirer.module.decode.dmr.DMRDecoder;
import io.github.dsheirer.module.decode.dmr.DMRDecoderState;
import io.github.dsheirer.module.decode.dmr.DMRTrafficChannelManager;
@@ -333,7 +334,9 @@ public class DecoderFactory
private static void processPassport(Channel channel, List modules, AliasList aliasList, DecodeConfiguration decodeConfig) {
modules.add(new PassportDecoder(decodeConfig));
modules.add(new PassportDecoderState());
- modules.add(new AudioModule(aliasList, AUDIO_FILTER_ENABLE));
+ List filterList = new ArrayList<>();
+ filterList.add(new HighPassAudioFilter());
+ modules.add(new AudioModule(aliasList, filterList));
if(channel.getSourceConfiguration().getSourceType() == SourceType.TUNER)
{
modules.add(new FMDemodulatorModule(FM_CHANNEL_BANDWIDTH));
@@ -357,14 +360,15 @@ public class DecoderFactory
ChannelMap channelMap = channelMapModel.getChannelMap(mptConfig.getChannelMapName());
Sync sync = mptConfig.getSync();
modules.add(new MPT1327Decoder(sync));
-
+ List filterList = new ArrayList<>();
+ filterList.add(new HighPassAudioFilter());
final int callTimeoutMilliseconds = mptConfig.getCallTimeoutSeconds() * 1000;
// Set max segment audio sample length slightly above call timeout to
// not create a new segment if the processing chain finishes a bit after
// actual call timeout.
long maxAudioSegmentLengthMillis = (callTimeoutMilliseconds + 5000);
- modules.add(new AudioModule(aliasList, AbstractAudioModule.DEFAULT_TIMESLOT, maxAudioSegmentLengthMillis, AUDIO_FILTER_ENABLE));
+ modules.add(new AudioModule(aliasList, AbstractAudioModule.DEFAULT_TIMESLOT, maxAudioSegmentLengthMillis, filterList));
SourceType sourceType = channel.getSourceConfiguration().getSourceType();
if(sourceType == SourceType.TUNER || sourceType == SourceType.TUNER_MULTIPLE_FREQUENCIES)
@@ -403,7 +407,9 @@ public class DecoderFactory
private static void processLTRNet(Channel channel, List modules, AliasList aliasList, DecodeConfigLTRNet decodeConfig) {
modules.add(new LTRNetDecoder(decodeConfig));
modules.add(new LTRNetDecoderState());
- modules.add(new AudioModule(aliasList, AUDIO_FILTER_ENABLE));
+ List filterList = new ArrayList<>();
+ filterList.add(new HighPassAudioFilter());
+ modules.add(new AudioModule(aliasList, filterList));
if(channel.getSourceConfiguration().getSourceType() == SourceType.TUNER)
{
modules.add(new FMDemodulatorModule(FM_CHANNEL_BANDWIDTH));
@@ -421,7 +427,9 @@ public class DecoderFactory
MessageDirection direction = decodeConfig.getMessageDirection();
modules.add(new LTRStandardDecoder(direction));
modules.add(new LTRStandardDecoderState());
- modules.add(new AudioModule(aliasList, AUDIO_FILTER_ENABLE));
+ List filterList = new ArrayList<>();
+ filterList.add(new HighPassAudioFilter());
+ modules.add(new AudioModule(aliasList, filterList));
if(channel.getSourceConfiguration().getSourceType() == SourceType.TUNER)
{
modules.add(new FMDemodulatorModule(FM_CHANNEL_BANDWIDTH));
@@ -444,9 +452,26 @@ public class DecoderFactory
}
DecodeConfigNBFM decodeConfigNBFM = (DecodeConfigNBFM)decodeConfig;
- modules.add(new NBFMDecoder(decodeConfigNBFM));
- modules.add(new NBFMDecoderState(channel.getName(), decodeConfigNBFM));
- modules.add(new AudioModule(aliasList, 0, 60000, decodeConfigNBFM.isAudioFilter()));
+ NBFMDecoderState decoderState = new NBFMDecoderState(channel.getName(), decodeConfigNBFM);
+ NBFMDecoder decoder = new NBFMDecoder(decodeConfigNBFM);
+ modules.add(decoder);
+ modules.add(decoderState);
+ List filterList = new ArrayList<>();
+ if(decodeConfigNBFM.isAudioFilter())
+ {
+ filterList.add(new HighPassAudioFilter());
+ }
+ if(decodeConfigNBFM.getDeemphasis() != null && decodeConfigNBFM.getDeemphasis() != DeemphasisMode.NONE)
+ {
+ float cutoff = (float) decodeConfigNBFM.getDeemphasis().getCutoff();
+ filterList.add(new DeemphasisFilter(8000, cutoff, 1.0f));
+ }
+ if(decodeConfigNBFM.isAudioALC())
+ {
+ filterList.add(new AudioGainFilter(1, 10, 0.90f));
+ }
+
+ modules.add(new AudioModule(aliasList, 0, 60000, filterList));
}
/**
@@ -462,7 +487,8 @@ public class DecoderFactory
{
modules.add(new AMDecoder(configAM));
modules.add(new AMDecoderState(channel.getName(), configAM));
- modules.add(new AudioModule(aliasList, 0, 60000, AUDIO_FILTER_ENABLE));
+ List filterList = new ArrayList<>(); // no default filtering for AM
+ modules.add(new AudioModule(aliasList, 0, 60000, filterList));
}
else
{
diff --git a/src/main/java/io/github/dsheirer/module/decode/DecoderType.java b/src/main/java/io/github/dsheirer/module/decode/DecoderType.java
index 5dcfafed..8df22320 100644
--- a/src/main/java/io/github/dsheirer/module/decode/DecoderType.java
+++ b/src/main/java/io/github/dsheirer/module/decode/DecoderType.java
@@ -40,6 +40,7 @@ public enum DecoderType
//Auxiliary Decoders
DCS("Digital Coded Squelch (DCS)", "DCS", Protocol.DCS),
+ CTCSS("Continuous Tone Squelch", "CTCSS", Protocol.CTCSS),
FLEETSYNC2("Fleetsync II", "Fleetsync2", Protocol.FLEETSYNC),
LJ_1200("LJ1200 173.075", "LJ1200", Protocol.LOJACK),
MDC1200("MDC1200", "MDC1200", Protocol.MDC1200),
diff --git a/src/main/java/io/github/dsheirer/module/decode/analog/AnalogDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/analog/AnalogDecoderState.java
index f7f1d180..2afebf78 100644
--- a/src/main/java/io/github/dsheirer/module/decode/analog/AnalogDecoderState.java
+++ b/src/main/java/io/github/dsheirer/module/decode/analog/AnalogDecoderState.java
@@ -93,7 +93,7 @@ public abstract class AnalogDecoderState extends DecoderState implements ISource
}
/**
- * Creates/starts a decode call evnet.
+ * Creates/starts a decode call event.
*/
private void startCallEvent()
{
diff --git a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessage.java b/src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessage.java
deleted file mode 100644
index 70fd35b4..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessage.java
+++ /dev/null
@@ -1,79 +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.dcs;
-
-import io.github.dsheirer.identifier.Identifier;
-import io.github.dsheirer.identifier.dcs.DCSIdentifier;
-import io.github.dsheirer.message.Message;
-import io.github.dsheirer.protocol.Protocol;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Digital Coded Squelch (DCS) tone detected message
- */
-public class DCSMessage extends Message
-{
- private final DCSCode mDCSCode;
-
- /**
- * Constructs an instance
- * @param code that was detected
- * @param timestamp when the code was detected
- */
- public DCSMessage(DCSCode code, long timestamp)
- {
- super(timestamp);
- mDCSCode = code;
- }
-
- @Override
- public String toString()
- {
- return "Digital Coded Squelch (DCS) Detected: " + mDCSCode.toString();
- }
-
- /**
- * The DCS code that was detected.
- * @return code
- */
- public DCSCode getDCSCode()
- {
- return mDCSCode;
- }
-
- @Override
- public boolean isValid()
- {
- return true; //We only send a message when the tone was valid
- }
-
- @Override
- public Protocol getProtocol()
- {
- return Protocol.DCS;
- }
-
- @Override
- public List getIdentifiers()
- {
- return Collections.singletonList(new DCSIdentifier(mDCSCode));
- }
-}
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 e0a22c4d..ef99f465 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-2025 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -19,23 +19,38 @@
package io.github.dsheirer.module.decode.nbfm;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
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.module.decode.squelch.SquelchDecoderConfig;
import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
+import java.util.ArrayList;
+import java.util.List;
+
/**
- * Decoder configuration for an NBFM channel
+ * Decoder configuration for an NBFM channel.
+ *
+ * Supports channel-level CTCSS/DCS tone filtering and FM de-emphasis.
*/
public class DecodeConfigNBFM extends DecodeConfigAnalog
{
private boolean mAudioFilter = true;
+ private boolean mAudioALC = false;
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;
+ // Channel-level squelch filtering
+ private List mSquelchDecoders = new ArrayList<>();
+
+ // FM de-emphasis
+ private DeemphasisMode mDeemphasis = DeemphasisMode.NONE;
+
+
/**
* Constructs an instance
*/
@@ -94,6 +109,25 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
mAudioFilter = audioFilter;
}
+ /**
+ * Indicates if the user wants automatic level control
+ * @return enable status, defaults to false;
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "audioALC")
+ public boolean isAudioALC()
+ {
+ return mAudioALC;
+ }
+
+ /**
+ * Sets the enabled state of high-pass filtering of the demodulated audio.
+ * @param audioALC set to enabled automatic level control (ALC)
+ */
+ public void setAudioALC(boolean audioALC)
+ {
+ mAudioALC = audioALC;
+ }
+
/**
* Squelch noise open threshold in the range 0.0 to 1.0 with a default of 0.1
* @return noise open threshold
@@ -104,6 +138,19 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
return mSquelchNoiseOpenThreshold;
}
+ /**
+ * 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;
+ }
+
/**
* 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
@@ -114,20 +161,6 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
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
@@ -138,7 +171,6 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
{
throw new IllegalArgumentException("Squelch noise close threshold is out of range: " + close);
}
-
mSquelchNoiseCloseThreshold = close;
}
@@ -162,7 +194,6 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
{
throw new IllegalArgumentException("Squelch hysteresis open threshold is out of range: " + open);
}
-
mSquelchHysteresisOpenThreshold = open;
}
@@ -186,7 +217,68 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog
{
throw new IllegalArgumentException("Squelch hysteresis close threshold is out of range: " + close);
}
-
mSquelchHysteresisCloseThreshold = close;
}
+
+ /**
+ * List of CTCSS/DCS squelch decoders for this channel.
+ */
+ @JacksonXmlElementWrapper(localName = "squelchDecoders")
+ @JacksonXmlProperty(localName = "squelchDecoder")
+ public List getSquelchDecoders()
+ {
+ return mSquelchDecoders;
+ }
+
+ public void setSquelchDecoders(List squelchDecoders)
+ {
+ mSquelchDecoders = squelchDecoders != null ? squelchDecoders : new ArrayList<>();
+ }
+
+ /**
+ * Adds a squelch decoder to the channel configuration
+ */
+ public void addSquelchDecoder(SquelchDecoderConfig decoder)
+ {
+ if(decoder != null)
+ {
+ mSquelchDecoders.add(decoder);
+ }
+ }
+
+ /**
+ * Removes a squelch filter from the channel configuration
+ */
+ public void removeSquelchDecoder(SquelchDecoderConfig decoder)
+ {
+ mSquelchDecoders.remove(decoder);
+ }
+
+ /**
+ * Indicates if squelch filtering is enabled for this channel
+ */
+ @JsonIgnore
+ public boolean isSquelchDecoderEnabled()
+ {
+ List decoders = getSquelchDecoders();
+ // TODO right now only looking at first and only decoder, need to fix when multiple decoders are possible
+ return !decoders.isEmpty() && decoders.getFirst().getSquelchType() != SquelchDecoderConfig.SquelchType.NONE;
+ }
+
+ /**
+ * FM de-emphasis mode. Standard FM broadcasting uses pre-emphasis to boost high
+ * frequencies during transmission. De-emphasis restores flat frequency response
+ * during receive, improving audio clarity.
+ * TIA-603-E is the US standard for NBFM de-emphasis.
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "deemphasis")
+ public DeemphasisMode getDeemphasis()
+ {
+ return mDeemphasis;
+ }
+
+ public void setDeemphasis(DeemphasisMode deemphasis)
+ {
+ mDeemphasis = deemphasis != null ? deemphasis : DeemphasisMode.NONE;
+ }
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/nbfm/DeemphasisMode.java b/src/main/java/io/github/dsheirer/module/decode/nbfm/DeemphasisMode.java
new file mode 100644
index 00000000..87ad27d5
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/nbfm/DeemphasisMode.java
@@ -0,0 +1,56 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.nbfm;
+
+/**
+ * FM de-emphasis
+ *
+ * Per TIA-603-E, all NBFM use a -6 dB per octave roll off from 300 Hz to 3000 Hz.
+ * It also specifies an additional -12 dB above 2500 (not implemented to save on filter passes, the resampler
+ * takes care of a lot of that), and an additional -6 dB below 500 (not implemented to save on filter passes, the
+ * existing high pass filter takes care of most of that). European standard has same specifications (unlike
+ * commercial FM, which the search engines struggle with).
+ */
+public enum DeemphasisMode
+{
+ NONE("None", 0),
+ //OTHER_166US("166 µs (Other)", 6024),
+ NBFM_300("-6dB/octave @ 300-3KHz", 300);
+
+ private final String mLabel;
+ private final int mCutoff;
+
+ DeemphasisMode(String label, int cutoffFreq)
+ {
+ mLabel = label;
+ mCutoff = cutoffFreq;
+ }
+
+ public int getCutoff()
+ {
+ return mCutoff;
+ }
+
+ @Override
+ public String toString()
+ {
+ return mLabel;
+ }
+}
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 c8f08363..67dfb5bd 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-2025 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -37,6 +37,14 @@ import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.SquelchControlDecoder;
+import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSMessage;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSDecoder;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSMessage;
+import io.github.dsheirer.module.decode.squelch.SquelchDecoderConfig;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSCode;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSDetector;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.sample.complex.IComplexSamplesListener;
@@ -46,10 +54,11 @@ import io.github.dsheirer.source.SourceEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.*;
+
/**
- * 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.
+ * NBFM decoder with integrated noise squelch, channel-level squelch tone decoders.
+ *
*/
public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventListener, IComplexSamplesListener,
Listener, IRealBufferProvider, IDecoderStateEventProvider, INoiseSquelchController
@@ -67,6 +76,12 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
private Listener mDecoderStateEventListener;
private RealResampler mResampler;
private final double mChannelBandwidth;
+ private boolean mSquelchDecoderEnabled = false;
+ private List mConfiguredCTCSSCodes = new ArrayList<>();
+ private Set mConfiguredDCSCodes = new HashSet<>();
+ private CTCSSDetector mCTCSSDetector = null;
+ private DCSDecoder mDCSDetector = null;
+ private boolean mMute = true;
/**
* Constructs an instance
@@ -82,7 +97,11 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
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.
+ // Configure squelch code decoders
+ configureSquelchDecoders(config);
+
+ // Audio pipeline: NoiseSquelch -> Resampler -> CTCSS or DCS -> 1200 baud decoders -> HPF -> De-emphasis -> gain -> Output
+
mNoiseSquelch.setAudioListener(audio -> {
// if squelch is closing (it hasn't propagated yet to mute the audio)
// call the resampler with lastBatch set to true. This will zero pad the input buffer and ensure
@@ -94,27 +113,91 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
else
{
mResampler.resample(audio); // this method will set lastBatch to false
- notifyCallContinuation();
+ if (!mSquelchDecoderEnabled)
+ {
+ notifyCallContinuation();
+ }
}
});
//Notify the decoder state of call starts and ends
mNoiseSquelch.setSquelchStateListener(squelchState -> {
- if(squelchState == SquelchState.SQUELCH)
+ if(squelchState == SquelchState.SQUELCH && !mSquelchDecoderEnabled)
{
notifyCallEnd();
}
else
{
- notifyCallStart();
+ // When tone filtering is enabled, delay call start until tone is matched.
+ // The call start will be triggered from ctcssDetected/dcsDetected instead.
+ if(!mSquelchDecoderEnabled)
+ {
+ notifyCallStart();
+ }
}
});
}
/**
- * Decoder type.
- * @return type
+ * Configures the set of allowed codes from the channel decode configuration
*/
+ private void configureSquelchDecoders(DecodeConfigNBFM config)
+ {
+ mSquelchDecoderEnabled = config.isSquelchDecoderEnabled();
+
+ if(mSquelchDecoderEnabled)
+ {
+ // at the present time, only a single decoder per channel is configured, however the playlist and other
+ // storage allows for multiple decoders per channel
+ List decoders = config.getSquelchDecoders();
+ for(SquelchDecoderConfig decoder : decoders)
+ {
+ if(!decoder.isValid())
+ {
+ continue;
+ }
+ switch(decoder.getSquelchType())
+ {
+ case CTCSS:
+ CTCSSCode ctcss = decoder.getCTCSSCode();
+ if(ctcss != null && ctcss != CTCSSCode.UNKNOWNH)
+ {
+ mConfiguredCTCSSCodes.add(ctcss);
+ }
+ break;
+ case DCS:
+ DCSCode dcs = decoder.getDCSCode();
+ if(dcs != null)
+ {
+ mConfiguredDCSCodes.add(dcs);
+ }
+ break;
+ }
+ }
+
+ // If we configured tone filtering but have no valid tones, disable it
+ if(mConfiguredCTCSSCodes.isEmpty() && mConfiguredDCSCodes.isEmpty())
+ {
+ mLog.warn("Tone filtering enabled but no valid CTCSS/DCS codes configured — disabling tone filter");
+ mSquelchDecoderEnabled = false;
+ }
+ else
+ {
+ // Create CTCSS detector if we have CTCSS codes to detect
+ if(!mConfiguredCTCSSCodes.isEmpty())
+ {
+ mCTCSSDetector = new CTCSSDetector(mConfiguredCTCSSCodes);
+ }
+
+ // Create DCS detector if we have DCS codes to detect
+ if(!mConfiguredDCSCodes.isEmpty())
+ {
+ mDCSDetector = new DCSDecoder(mConfiguredDCSCodes);
+ }
+ }
+ }
+ }
+
@Override
public DecoderType getDecoderType()
{
@@ -198,7 +281,7 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
public void start() {}
@Override
- public void stop() {}
+ public void stop() {}
/**
* Broadcasts the demodulated, resampled to 8 kHz audio samples to the registered listener.
@@ -261,7 +344,13 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
float[] filteredQ = mQBasebandFilter.filter(decimatedQ);
float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ);
-
+ /**
+ * The following call completes a series of tasks:
+ * - determines noise squelch states
+ * - resamples audio via the squelch listener
+ * - processes resample audio for squelch decoders via the resampler listener
+ * - broadcasts audio to downstream registered listeners, such as audioModule as determined by DecoderFactory
+ */
mNoiseSquelch.process(demodulated);
//Once we process the sample buffer, if the ending state is squelch closed, update the decoder state that we
@@ -372,7 +461,16 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
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
+ FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
+ .sampleRate(decimatedSampleRate * 2)
+ .gridDensity(16)
+ .oddLength(true)
+ .passBandCutoff(passBandStop)
+ .passBandAmplitude(1.0)
+ .passBandRipple(0.01)
+ .stopBandStart(stopBandStart)
+ .stopBandAmplitude(0.0)
+ .stopBandRipple(0.005)
.build();
try
@@ -394,7 +492,85 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);
mResampler = new RealResampler(decimatedSampleRate, DEMODULATED_AUDIO_SAMPLE_RATE, 4192, 512);
- mResampler.setListener(NBFMDecoder.this::broadcast);
+
+ mResampler.setListener(NBFMDecoder.this::processResampledAudio);
+
+ }
+
+ /**
+ * Process the Resampled audio for squelch decoding. This also where audio is muted if no tone code match.
+ * @param resampled audio buffer
+ */
+ private void processResampledAudio(float [] resampled)
+ {
+ if(!mNoiseSquelch.isSquelched())
+ {
+ if(mCTCSSDetector != null)
+ {
+ CTCSSMessage ctcssMessage = mCTCSSDetector.process(resampled);
+ getMessageListener().receive(ctcssMessage); // sending: one of the listeners is NBFMDecoderState
+ if(ctcssMessage.getCallEvent() != null)
+ {
+ // handles START, CONTINUATION, END.
+ broadcast(new DecoderStateEvent(this, ctcssMessage.getCallEvent(), State.CALL, 0));
+ }
+ mMute = ctcssMessage.getMutedStatus();
+ }
+ /*
+ * While the CTCSS decoder can determine a tone in a single buffer of resampled audio, the DCS decoder
+ * requires 2.6 buffers to determine a code. DCSMessage will be null for audio buffers where there is
+ * no state change.
+ */
+ if(mDCSDetector != null)
+ {
+ DCSMessage dcsMessage = mDCSDetector.process(resampled);
+ if (dcsMessage != null)
+ {
+ getMessageListener().receive(dcsMessage); // sending: one of the listeners is NBFMDecoderState
+ if (dcsMessage.getCallEvent() != null)
+ {
+ // handles START, CONTINUATION, END.
+ broadcast(new DecoderStateEvent(this, dcsMessage.getCallEvent(), State.CALL, 0));
+ }
+ mMute = dcsMessage.getMutedStatus();
+ }
+ }
+ }
+ else
+ {
+ // there can still be one buffer's worth of resampled audio after .isSquelched()
+ // which may result in two consecutive paths here. While the squelch decoders will mute here,
+ // the noise squelch will mute audio downstream in AudioModule (via broadcast(resampled) below).
+ if(mCTCSSDetector != null)
+ {
+ mCTCSSDetector.reset();
+ CTCSSMessage message = new CTCSSMessage(mConfiguredCTCSSCodes.getFirst(),
+ "Noise squelch closed.",
+ DecoderStateEvent.Event.END,
+ SquelchCodeState.LOST);
+ getMessageListener().receive(message); // sending: one of the listeners is NBFMDecoderState
+ notifyCallEnd();
+ mMute = true;
+ }
+ if(mDCSDetector != null)
+ {
+ mDCSDetector.inlineReset();
+ DCSMessage message = new DCSMessage(mDCSDetector.getmConfiguredCode(),
+ "Noise squelch closed.",
+ DecoderStateEvent.Event.END,
+ SquelchCodeState.LOST);
+ getMessageListener().receive(message); // sending: one of the listeners is NBFMDecoderState
+ notifyCallEnd();
+ mMute = true;
+ }
+ }
+
+ if(mSquelchDecoderEnabled && mMute)
+ {
+ return; // drop audio buffer here for muting
+ }
+
+ broadcast(resampled);
}
/**
diff --git a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoderState.java
index e6cb4299..125fce8c 100644
--- a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoderState.java
+++ b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoderState.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,11 +24,22 @@ import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.string.SimpleStringIdentifier;
+import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.analog.AnalogDecoderState;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSMessage;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSMessage;
+import io.github.dsheirer.module.decode.squelch.SquelchDecoderConfig;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSCode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
/**
- * NBFM decoder state
+ * NBFM decoder state - tracks channel tone filtering configuration and detected tones
*/
public class NBFMDecoderState extends AnalogDecoderState
{
@@ -36,16 +47,32 @@ public class NBFMDecoderState extends AnalogDecoderState
private Identifier mChannelNameIdentifier;
private Identifier mTalkgroupIdentifier;
+ // Tone filter configuration (from DecodeConfigNBFM)
+ private boolean mSquelchDecoderEnabled = false;
+ private List mConfiguredSquelchDecoders = new ArrayList<>();
+
+ // Current status
+ private volatile String mToneStatus = "No tone detected";
+
+ // Per-tone detection counts: key = display string, value = [acceptCount, rejectCount]
+ private final Map mToneCounts = new LinkedHashMap<>();
+
/**
* Constructs an instance
* @param channelName to use for this channel
- * @param decodeConfig with talkgroup identifier
+ * @param decodeConfig with talkgroup identifier and tone filter settings
*/
public NBFMDecoderState(String channelName, DecodeConfigNBFM decodeConfig)
{
mChannelName = (channelName != null && !channelName.isEmpty()) ? channelName : "NBFM CHANNEL";
mChannelNameIdentifier = new SimpleStringIdentifier(mChannelName, IdentifierClass.CONFIGURATION, Form.CHANNEL_NAME, Role.ANY);
mTalkgroupIdentifier = new NBFMTalkgroup(decodeConfig.getTalkgroup());
+
+ mSquelchDecoderEnabled = decodeConfig.isSquelchDecoderEnabled();
+ if(mSquelchDecoderEnabled && decodeConfig.getSquelchDecoders() != null)
+ {
+ mConfiguredSquelchDecoders.addAll(decodeConfig.getSquelchDecoders());
+ }
}
@Override
@@ -66,13 +93,171 @@ public class NBFMDecoderState extends AnalogDecoderState
return mTalkgroupIdentifier;
}
+ /**
+ * Called by IMessage receive when a matching CTCSS tone is detected (allowed)
+ */
+ public void setAcceptedCTCSS(CTCSSCode code)
+ {
+ if(code != null)
+ {
+ mToneStatus = "CTCSS: " + code.getDisplayString() + " [ALLOWED]";
+ incrementCount(code.getDisplayString(), true);
+ }
+ }
+
+ /**
+ * Called by IMessage receive when a matching DCS code is detected (allowed)
+ */
+ public void setAcceptedDCS(DCSCode code)
+ {
+ if(code != null)
+ {
+ mToneStatus = "DCS: " + code.toString() + " [ALLOWED]";
+ incrementCount("DCS " + code.toString(), true);
+ }
+ }
+
+ /**
+ * Called by IMessage receive when a non-matching CTCSS tone is detected (rejected)
+ */
+ public void setRejectedCTCSS(CTCSSCode code)
+ {
+ if(code != null)
+ {
+ mToneStatus = "CTCSS: " + code.getDisplayString() + " [REJECTED]";
+ incrementCount(code.getDisplayString(), false);
+ }
+ }
+
+ /**
+ * Called by IMessage receive when a non-matching DCS code is detected (rejected)
+ */
+ public void setRejectedDCS(DCSCode code)
+ {
+ if(code != null)
+ {
+ mToneStatus = "DCS: " + code.toString() + " [REJECTED]";
+ incrementCount("DCS " + code.toString(), false);
+ }
+ }
+
+ /**
+ * Called by IMessage receive when tone is lost
+ */
+ public void setToneLost()
+ {
+ mToneStatus = "No tone detected";
+ getIdentifierCollection().remove(Form.TONE);
+ }
+
+ private void incrementCount(String toneLabel, boolean accepted)
+ {
+ synchronized(mToneCounts)
+ {
+ int[] counts = mToneCounts.computeIfAbsent(toneLabel, k -> new int[]{0, 0});
+ if(accepted)
+ {
+ if (counts[0] < Integer.MAX_VALUE)
+ {
+ counts[0]++;
+ }
+ }
+ else
+ {
+ if(counts[1] < Integer.MAX_VALUE)
+ {
+ counts[1]++;
+ }
+ }
+ }
+ }
+
+ /**
+ * Provides information for UI display in Now Playing panel.
+ */
@Override
public String getActivitySummary()
{
StringBuilder sb = new StringBuilder();
- sb.append("Activity Summary\n");
- sb.append("\tDecoder: NBFM");
- sb.append("\n\n");
+ sb.append("Activity Summary - Decoder:NBFM\n");
+
+ sb.append("\n\nSquelch Decoder: ");
+ if(mSquelchDecoderEnabled)
+ {
+ sb.append("ENABLED\n");
+ sb.append("Configured decoder: ");
+ if(!mConfiguredSquelchDecoders.isEmpty())
+ {
+ sb.append(mConfiguredSquelchDecoders.get(0).getDisplayString());
+ }
+ sb.append("\n");
+ }
+ else
+ {
+ sb.append("Disabled\n");
+ }
+
+ synchronized(mToneCounts)
+ {
+ if(!mToneCounts.isEmpty())
+ {
+ sb.append("\nDetected codes:\n");
+ for(Map.Entry entry : mToneCounts.entrySet())
+ {
+ String status;
+ int accepted = entry.getValue()[0];
+ int rejected = entry.getValue()[1];
+ if(accepted > 0)
+ {
+ status = accepted < Integer.MAX_VALUE ? "ALLOWED (" + accepted + ")" : "ALLOWED (>Max value)";
+ }
+ else
+ {
+ status = rejected < Integer.MAX_VALUE ? "REJECTED (" + rejected + ")" : "REJECTED (>Max value)";
+ }
+ sb.append("\t").append(entry.getKey()).append(" - ").append(status).append("\n");
+ }
+ }
+ }
+ sb.append("\n");
return sb.toString();
}
+
+ /**
+ * Listener for IMessages originating in NBFM decoder. If the squelch decoders are enabled,
+ * the message contains information on decoded squelch code (CTCSS/DCS) which is added to the
+ * identifier collection. A tally is collected for codes accepted and rejected for use
+ * in getActivitySummary.
+ *
+ */
+ @Override
+ public void receive(IMessage message)
+ {
+ if(message instanceof CTCSSMessage ctcssMessage)
+ {
+ getIdentifierCollection().update(ctcssMessage.getIdentifiers());
+ if (ctcssMessage.getCodeState() != null)
+ {
+ switch (ctcssMessage.getCodeState())
+ {
+ case ACCEPTED -> setAcceptedCTCSS(ctcssMessage.getCTCSSCode());
+ case REJECTED -> setRejectedCTCSS(ctcssMessage.getCTCSSCode());
+ case LOST -> setToneLost();
+ }
+ }
+ }
+ if(message instanceof DCSMessage dcsMessage)
+ {
+ getIdentifierCollection().update(dcsMessage.getIdentifiers());
+ if (dcsMessage.getCodeState() != null)
+ {
+ switch (dcsMessage.getCodeState())
+ {
+ case ACCEPTED -> setAcceptedDCS(dcsMessage.getDCSCode());
+ case REJECTED -> setRejectedDCS(dcsMessage.getDCSCode());
+ case LOST -> setToneLost();
+ }
+ }
+ }
+ }
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchCodeState.java b/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchCodeState.java
new file mode 100644
index 00000000..81c1974f
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchCodeState.java
@@ -0,0 +1,32 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch;
+
+/**
+ * SquelchCodeState is used in iMessage subclasses such as CTCSSMessage and DCSMessage, and the
+ * NBFMDecoderState to indicate the state of the current squelch code being received.
+ */
+
+public enum SquelchCodeState
+{
+ ACCEPTED,
+ REJECTED,
+ LOST
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchDecoderConfig.java b/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchDecoderConfig.java
new file mode 100644
index 00000000..4e485578
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/SquelchDecoderConfig.java
@@ -0,0 +1,210 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import io.github.dsheirer.module.decode.squelch.ctcss.CTCSSCode;
+import io.github.dsheirer.module.decode.squelch.dcs.DCSCode;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+
+/**
+ * Channel-level tone/code filter configuration. Supports CTCSS & DCS.
+ * Audio is only passed when at least one
+ * configured filter matches the received signal.
+ */
+public class SquelchDecoderConfig
+{
+
+ public enum SquelchType
+ {
+ NONE("None"),
+ CTCSS("CTCSS"),
+ DCS("DCS");
+
+ public static final Set SQUELCH_TYPE ;
+ static
+ {
+ EnumSet types = EnumSet.allOf(SquelchType.class);
+ SQUELCH_TYPE = java.util.Collections.unmodifiableSet(types);
+ }
+ private final String mLabel;
+
+ SquelchType(String label)
+ {
+ mLabel = label;
+ }
+
+ @Override
+ public String toString()
+ {
+ return mLabel;
+ }
+ }
+
+ private SquelchType mSquelchType = SquelchType.NONE;
+ private String mValue = "";
+
+ /**
+ * Default constructor for Jackson XML deserialization
+ */
+ public SquelchDecoderConfig()
+ {
+ }
+
+ /**
+ * Constructs an instance
+ * @param squelchType of this filter (CTCSS, DCS)
+ * @param value the tone/code value as a string
+ */
+ public SquelchDecoderConfig(SquelchType squelchType, String value)
+ {
+ mSquelchType = squelchType;
+ mValue = value;
+ }
+
+ /**
+ * Squelch filter type (None, CTCSS, DCS)
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "squelchType")
+ public SquelchType getSquelchType()
+ {
+ return mSquelchType;
+ }
+
+ public void setSquelchType(SquelchType squelchType)
+ {
+ mSquelchType = squelchType;
+ }
+
+ /**
+ * Value for the tone/code filter.
+ * For CTCSS: the CTCSSCode enum name is the frequency (e.g. "179.9")
+ * For DCS: the DCSCode enum name (e.g. "D023N")
+ */
+ @JacksonXmlProperty(isAttribute = true, localName = "value")
+ public String getValue()
+ {
+ return mValue;
+ }
+
+ public void setValue(String value)
+ {
+ mValue = value;
+ }
+
+ /**
+ * Returns the CTCSS code for this filter, or null if not a CTCSS filter or value is invalid.
+ */
+ @JsonIgnore
+ public CTCSSCode getCTCSSCode()
+ {
+ if(mSquelchType == SquelchType.CTCSS && mValue != null && !mValue.isEmpty())
+ {
+ try
+ {
+ return CTCSSCode.valueOf(mValue);
+ }
+ catch(IllegalArgumentException e)
+ {
+ // Try to parse as frequency
+ try
+ {
+ float freq = Float.parseFloat(mValue);
+ return CTCSSCode.fromFrequency(freq);
+ }
+ catch(NumberFormatException nfe)
+ {
+ return null;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the DCS code for this filter, or null if not a DCS filter or value is invalid.
+ */
+ @JsonIgnore
+ public DCSCode getDCSCode()
+ {
+ if(mSquelchType == SquelchType.DCS && mValue != null && !mValue.isEmpty())
+ {
+ try
+ {
+ return DCSCode.valueOf(mValue);
+ }
+ catch(IllegalArgumentException e)
+ {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Indicates if this filter configuration is valid
+ */
+ @JsonIgnore
+ public boolean isValid()
+ {
+ return switch(mSquelchType)
+ {
+ case CTCSS -> getCTCSSCode() != null && getCTCSSCode() != CTCSSCode.UNKNOWNH;
+ case DCS -> getDCSCode() != null;
+ case NONE -> false;
+ };
+ }
+
+ /**
+ * Display string for this filter
+ */
+ @JsonIgnore
+ public String getDisplayString()
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.append(mSquelchType).append(": ");
+
+ switch(mSquelchType)
+ {
+ case CTCSS:
+ CTCSSCode ctcss = getCTCSSCode();
+ sb.append(ctcss != null ? ctcss.getDisplayString() : mValue);
+ break;
+ case DCS:
+ DCSCode dcs = getDCSCode();
+ sb.append(dcs != null ? dcs.toString() : mValue);
+ break;
+ case NONE:
+ sb.append("No squelch filter configured.");
+ break;
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public String toString()
+ {
+ return getDisplayString();
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSCode.java b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSCode.java
new file mode 100644
index 00000000..86cbfddb
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSCode.java
@@ -0,0 +1,159 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch.ctcss;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * Standard CTCSS (Continuous Tone-Coded Squelch System) tones.
+ * Also known as PL (Private Line) tones (Motorola trademark).
+ *
+ * Frequencies and EIA/TIA standard designators from TIA-603.
+ *
+ * Two dummy tones are at each end of the spectrum. It was noted during testing that when a tone was not
+ * present, the algorithm would drift to the lowest one, and we don't want a false detection when in fact there
+ * is no tone at all. Testing never revealed the highest tone falsely detected, but want completeness.
+ */
+public enum CTCSSCode
+{
+ UNKNOWNL(65.0f, "UKNL", "65.0"),
+ TONE_670(67.0f, "XZ", "67.0"),
+ TONE_693(69.3f, "WZ", "69.3"),
+ TONE_719(71.9f, "XA", "71.9"),
+ TONE_744(74.4f, "WA", "74.4"),
+ TONE_770(77.0f, "XB", "77.0"),
+ TONE_797(79.7f, "WB", "79.7"),
+ TONE_825(82.5f, "YZ", "82.5"),
+ TONE_854(85.4f, "YA", "85.4"),
+ TONE_885(88.5f, "YB", "88.5"),
+ TONE_915(91.5f, "ZZ", "91.5"),
+ TONE_948(94.8f, "ZA", "94.8"),
+ TONE_974(97.4f, "ZB", "97.4"),
+ TONE_1000(100.0f, "1Z", "100.0"),
+ TONE_1035(103.5f, "1A", "103.5"),
+ TONE_1072(107.2f, "1B", "107.2"),
+ TONE_1109(110.9f, "2Z", "110.9"),
+ TONE_1148(114.8f, "2A", "114.8"),
+ TONE_1188(118.8f, "2B", "118.8"),
+ TONE_1230(123.0f, "3Z", "123.0"),
+ TONE_1273(127.3f, "3A", "127.3"),
+ TONE_1318(131.8f, "3B", "131.8"),
+ TONE_1365(136.5f, "4Z", "136.5"),
+ TONE_1413(141.3f, "4A", "141.3"),
+ TONE_1462(146.2f, "4B", "146.2"),
+ TONE_1514(151.4f, "5Z", "151.4"),
+ TONE_1567(156.7f, "5A", "156.7"),
+ TONE_1622(162.2f, "5B", "162.2"),
+ TONE_1679(167.9f, "6Z", "167.9"),
+ TONE_1738(173.8f, "6A", "173.8"),
+ TONE_1799(179.9f, "6B", "179.9"),
+ TONE_1862(186.2f, "7Z", "186.2"),
+ TONE_1928(192.8f, "7A", "192.8"),
+ TONE_2035(203.5f, "M1", "203.5"),
+ TONE_2065(206.5f, "M2", "206.5"),
+ TONE_2107(210.7f, "M3", "210.7"),
+ TONE_2181(218.1f, "M4", "218.1"),
+ TONE_2257(225.7f, "M5", "225.7"),
+ TONE_2291(229.1f, "M6", "229.1"),
+ TONE_2336(233.6f, "M7", "233.6"),
+ TONE_2418(241.8f, "8Z", "241.8"),
+ TONE_2503(250.3f, "9Z", "250.3"),
+ TONE_2541(254.1f, "0Z", "254.1"),
+ UNKNOWNH(260.0f, "UKNH", "260.0");
+ /**
+ * Set of all standard (non-UNKNOWN) CTCSS codes
+ */
+ public static final Set STANDARD_CODES;
+ public static final Set DETECTING_CODES;
+
+ static
+ {
+ EnumSet codes = EnumSet.allOf(CTCSSCode.class);
+ DETECTING_CODES = java.util.Collections.unmodifiableSet(codes);
+ codes.remove(UNKNOWNL);
+ codes.remove(UNKNOWNH);
+ STANDARD_CODES = java.util.Collections.unmodifiableSet(codes);
+ }
+
+ private final float mFrequency;
+ private final String mDesignator;
+ private final String mFrequencyLabel;
+
+ CTCSSCode(float frequency, String designator, String frequencyLabel)
+ {
+ mFrequency = frequency;
+ mDesignator = designator;
+ mFrequencyLabel = frequencyLabel;
+ }
+
+ /**
+ * Tone frequency in Hz
+ */
+ public float getFrequency()
+ {
+ return mFrequency;
+ }
+
+ /**
+ * EIA/TIA standard designator (e.g. "XZ", "1A", "M7")
+ */
+ public String getDesignator()
+ {
+ return mDesignator;
+ }
+
+ /**
+ * Display string showing frequency and designator
+ */
+ public String getDisplayString()
+ {
+ return mFrequencyLabel + " Hz (" + mDesignator + ")";
+ }
+
+ @Override
+ public String toString()
+ {
+ return getDisplayString();
+ }
+
+ /**
+ * Finds the CTCSSCode closest to the given frequency within ±1 Hz tolerance.
+ * @param frequency in Hz
+ * @return matching code or UNKNOWN if no match
+ */
+ public static CTCSSCode fromFrequency(float frequency)
+ {
+ CTCSSCode best = UNKNOWNH;
+ float bestDelta = Float.MAX_VALUE;
+
+ for(CTCSSCode code : STANDARD_CODES)
+ {
+ float delta = Math.abs(code.mFrequency - frequency);
+ if(delta < bestDelta)
+ {
+ bestDelta = delta;
+ best = code;
+ }
+ }
+
+ return bestDelta <= 1.0f ? best : UNKNOWNH;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSDetector.java b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSDetector.java
new file mode 100644
index 00000000..1aa8c7d4
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSDetector.java
@@ -0,0 +1,336 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch.ctcss;
+
+import io.github.dsheirer.channel.state.DecoderStateEvent;
+import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Real-time CTCSS tone detector using the Goertzel algorithm.
+ *
+ * Processing approach:
+ * - Analyze an 8 KHz audio block of 512 samples
+ * - Runs Goertzel on each standard frequency
+ * - Run a standard deviation on the array of detected tones to determine a threshold
+ * - Reports the strongest matching tone if it exceeds the threshold
+ * Processing can be logged when the decoded messages log is turned on in the channel configuration of the playlist
+ * or by viewing the messages tab in the Now Playing window.
+ */
+public class CTCSSDetector
+{
+ private static final Logger mLog = LoggerFactory.getLogger(CTCSSDetector.class);
+ private static final double RESAMPLED_AUDIO_SAMPLE_RATE = 8000.0;
+ private static final int MIN_BLOCK_SIZE = 512;
+
+ /**
+ * K factor is the multiplier * std deviation which determines threshold
+ */
+ private static final float K_FACTOR = 3.0f;
+ /**
+ * Number of consecutive detections required before reporting a match.
+ * The first and last detected tones in a transmission tend to be incorrect,
+ * so we require at least 2 detections up front and at the end.
+ */
+ private static final int OPEN_THRESHOLD_COUNT = 2;
+ private static final int CLOSE_THRESHOLD_COUNT = 2;
+ // Detection states
+ private int mOpenCounter = 0;
+ private int mCloseCounter = 0;
+ private boolean mMuted = true;
+ private int mRejectedQualification = 0;
+ private CTCSSCode mPreviousRejectedCode = null;
+
+ private final List mTargetCodes; // codes we are looking to match in this channel
+ private final float[] mDetectingFrequencies;
+ private final CTCSSCode[] mDetectingCodeArray; // codes we are detecting
+ // Goertzel coefficients for each target frequency
+ private final double[] mCoefficients;
+
+ /**
+ * Constructs a CTCSS detector for a specific set of target tones.
+ *
+ * @param targetCodes the set of CTCSS codes to accept as matches. If null or empty, accepts all standard codes.
+ */
+ public CTCSSDetector(List targetCodes)
+ {
+ mTargetCodes = targetCodes;
+
+ // Detect all codes including one on each end of the spectrum (DETECTING_CODES)
+ // We find the strongest tone across all frequencies, then check if it's in our allowed set.
+ Set allCodes = CTCSSCode.DETECTING_CODES;
+ mDetectingCodeArray = allCodes.toArray(new CTCSSCode[0]);
+ mDetectingFrequencies = new float[mDetectingCodeArray.length];
+ mCoefficients = new double[mDetectingCodeArray.length];
+
+ for(int i = 0; i < mDetectingCodeArray.length; i++)
+ {
+ mDetectingFrequencies[i] = mDetectingCodeArray[i].getFrequency();
+ }
+
+ // Pre-compute Goertzel coefficients: 2 * cos(2π * freq / sampleRate)
+ for(int i = 0; i < mDetectingFrequencies.length; i++)
+ {
+ double normalizedFreq = mDetectingFrequencies[i] / RESAMPLED_AUDIO_SAMPLE_RATE;
+ mCoefficients[i] = (2.0 * Math.cos(2.0 * Math.PI * normalizedFreq));
+ }
+ }
+
+ /**
+ * Processes a buffer of demodulated FM audio samples.
+ * Goertzel analysis performed on a block of 512 audio samples and the relative power is calculated
+ * for each CTCSS tone and placed into a frequency distribution array. The standard deviation of the array
+ * is calculated to determine threshold based on the noise. The highest powered tone must exceed this threshold
+ * to be considered for match detection. Testing revealed that there can be multiple peaks present in
+ * the distribution that were considered to be interference rather than desired signals.
+ *
+ * @param samples demodulated audio samples
+ * @return CTCSSMessage for broadcasting state to listeners
+ */
+ public CTCSSMessage process(float[] samples)
+ {
+ CTCSSMessage ctcssMessage = new CTCSSMessage(mTargetCodes.getFirst());
+ double[] distribution = new double[mDetectingFrequencies.length];
+ double maxPower = 0;
+ int maxIndex = -1;
+ if(samples == null || samples.length < MIN_BLOCK_SIZE)
+ {
+ return null;
+ }
+
+ for(int i = 0; i < mDetectingFrequencies.length; i++)
+ {
+ double power = goertzel(samples, samples.length, mCoefficients[i]);
+ distribution[i] = power;
+
+ if(power > maxPower)
+ {
+ maxPower = power;
+ maxIndex = i;
+ }
+ }
+ // Testing shows that if the power is below approx. 100, the tone can't be detected reliably
+ // The lowest-most and upper-most tones are not valid CTCSS tones.
+ if(maxPower < 100 || maxIndex == 0 || maxIndex == mDetectingFrequencies.length - 1)
+ {
+ // skip further detection
+ ctcssMessage.setFirstThreshold(false);
+ ctcssMessage.setMessage("Signal too weak or detected CTCSS tone is outside of range of valid tones");
+ updateDetectionState(null, ctcssMessage);
+ return ctcssMessage;
+ }
+
+ ctcssMessage.setFirstThreshold(true);
+ // Determine threshold based on the noise level in the distribution using standard deviation.
+ // adjacent channel interference from strong signals will cause multiple spikes in the distribution
+ double sum = 0.0;
+ for (double num : distribution)
+ {
+ sum += num;
+ }
+ double mean = sum / distribution.length;
+ double varianceSum = 0.0;
+ for (double num : distribution)
+ {
+ varianceSum += Math.pow(num - mean, 2);
+ }
+
+ double variance = varianceSum / (distribution.length - 1); // sample type of std dev
+ double stdDev = Math.sqrt(variance);
+ double threshold = (stdDev * K_FACTOR) + mean;
+ ctcssMessage.setPower(maxPower);
+ ctcssMessage.setPowerThreshold(threshold);
+ if(maxPower > threshold)
+ {
+ CTCSSCode detected = mDetectingCodeArray[maxIndex];
+ updateDetectionState(detected, ctcssMessage);
+ }
+ else
+ {
+ updateDetectionState(null, ctcssMessage); // set the code to null
+ }
+
+ return ctcssMessage;
+ }
+
+ /**
+ * Goertzel algorithm - computes the energy at a specific frequency.
+ *
+ * @param samples the input samples
+ * @param numSamples number of samples to process
+ * @param coefficient pre-computed 2*cos(2π*f/fs)
+ * @return the magnitude squared at the target frequency
+ */
+ private double goertzel(float[] samples, int numSamples, double coefficient)
+ {
+ double s0 = 0;
+ double s1 = 0;
+ double s2 = 0;
+
+ for(int i = 0; i < numSamples; i++)
+ {
+ s0 = samples[i] + (coefficient * s1) - s2;
+ s2 = s1;
+ s1 = s0;
+ }
+
+ // Magnitude squared = s1^2 + s2^2 - coefficient * s1 * s2
+ return (s1 * s1) + (s2 * s2) - (coefficient * s1 * s2);
+ }
+
+ /**
+ * Handles detection logic of a CTCSS tone in the current block and updates
+ * the CTCSSMessage states.
+ * @param newCode currently detected CTCSS code, or null if no code detected
+ * @param ctcssMessage current ctcssMessage for updating
+ */
+ private void updateDetectionState(CTCSSCode newCode, CTCSSMessage ctcssMessage)
+ {
+ if (mMuted)
+ {
+ if (newCode != null && mTargetCodes.contains(newCode))
+ {
+ if (mOpenCounter >= OPEN_THRESHOLD_COUNT)
+ {
+ // unmute and report detected code
+ mMuted = false;
+ ctcssMessage.setMutedStatus(false);
+ ctcssMessage.setCTCSSCode(newCode);
+ ctcssMessage.setMessage("Correct tone detected, thresholds passed, now unmuting.");
+ ctcssMessage.setCallEvent(DecoderStateEvent.Event.START);
+ ctcssMessage.setCodeState(SquelchCodeState.ACCEPTED);
+ mCloseCounter = 0;
+ }
+ else
+ {
+ // increment counter and wait for next audio buffer
+ mOpenCounter++;
+ ctcssMessage.setCTCSSCode(newCode);
+ ctcssMessage.setMessage("Waiting for consecutive correct detections.");
+ }
+ }
+ else // wrong tone or no tone, still muted
+ {
+ mOpenCounter = 0;
+ ctcssMessage.setMutedStatus(true);
+ ctcssMessage.setCTCSSCode(newCode);
+ if(newCode != null)
+ {
+ /*
+ * Qualify the rejected code so that the information in the Now Playing -> Details tab
+ * is accurate in case someone doesn't know the correct squelch code for this channel.
+ * We don't want to display codes that are just single detections in noise.
+ */
+ if(mRejectedQualification == OPEN_THRESHOLD_COUNT)
+ {
+ if(newCode == mPreviousRejectedCode)
+ {
+ ctcssMessage.setCodeState(SquelchCodeState.REJECTED);
+ // this will increment past the OPEN_THRESHOLD_COUNT so the rejected state is only sent once.
+ mRejectedQualification++;
+ }
+ else
+ {
+ mRejectedQualification = 0;
+ mPreviousRejectedCode = newCode;
+ }
+ }
+ else
+ {
+ if(newCode == mPreviousRejectedCode)
+ {
+ mRejectedQualification++;
+
+ }
+ else
+ {
+ mRejectedQualification = 0;
+ mPreviousRejectedCode = newCode;
+ }
+ }
+ }
+ else
+ {
+ ctcssMessage.setCodeState(SquelchCodeState.LOST);
+ }
+ }
+ }
+ else // unmuted and call is ongoing
+ {
+ if (newCode != null && mTargetCodes.contains(newCode))
+ {
+ // all is good, call continues
+ mCloseCounter = 0;
+ ctcssMessage.setMutedStatus(false);
+ ctcssMessage.setCTCSSCode(newCode);
+ ctcssMessage.setCallEvent(DecoderStateEvent.Event.CONTINUATION);
+ }
+ else
+ {
+ if (mCloseCounter >= CLOSE_THRESHOLD_COUNT)
+ {
+ // Mute audio and report rejected code or lost tone
+ mMuted = true;
+ ctcssMessage.setMutedStatus(true);
+ ctcssMessage.setCTCSSCode(newCode);
+ ctcssMessage.setMessage("Incorrect tone detected or lost, now muting.");
+ ctcssMessage.setCallEvent(DecoderStateEvent.Event.END);
+ mOpenCounter = 0;
+ if(newCode != null)
+ {
+ ctcssMessage.setCodeState(SquelchCodeState.REJECTED);
+ }
+ else
+ {
+ ctcssMessage.setCodeState(SquelchCodeState.LOST);
+ }
+ }
+ else
+ {
+ // all is still good with the call, but a bad tone was decoded and the closeCounter hasn't
+ // reached a threshold yet.
+ mCloseCounter++;
+ ctcssMessage.setMutedStatus(false);
+ ctcssMessage.setCTCSSCode(newCode);
+ ctcssMessage.setMessage("Waiting for consecutive incorrect or no tone.");
+ ctcssMessage.setCallEvent(DecoderStateEvent.Event.CONTINUATION);
+ }
+ }
+ }
+ }
+
+ /**
+ * Resets the detector state. Called when the noise squelch closes
+ */
+ public void reset()
+ {
+ mMuted = true;
+ mCloseCounter = 0;
+ mOpenCounter = 0;
+ mPreviousRejectedCode = null;
+ mRejectedQualification = 0;
+ }
+
+}
+
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSMessage.java b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSMessage.java
new file mode 100644
index 00000000..d48a7b59
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/ctcss/CTCSSMessage.java
@@ -0,0 +1,179 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch.ctcss;
+
+import io.github.dsheirer.channel.state.DecoderStateEvent;
+import io.github.dsheirer.identifier.Identifier;
+import io.github.dsheirer.identifier.ctcss.CTCSSIdentifier;
+import io.github.dsheirer.message.Message;
+import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
+import io.github.dsheirer.protocol.Protocol;
+
+import java.text.MessageFormat;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * CTCSS Detector message carrying CTCSS code, state and debug info for distribution
+ */
+public class CTCSSMessage extends Message
+{
+ // null is a necessary default for many of these
+ private CTCSSCode mConfiguredCode = null;
+ private CTCSSCode mCTCSSCode = null;
+ private String mDebugMessage = null;
+ private CTCSSIdentifier mIdentifier = null;
+ private boolean mMutedStatus = true;
+ private boolean mFirstThreshold = false;
+ private double mPower = 0.0;
+ private double mPowerThreshold = 0.0;
+ private DecoderStateEvent.Event mCallEvent = null;
+ private SquelchCodeState mCodeState = null;
+
+ /**
+ * Constructs an instance
+ */
+ public CTCSSMessage()
+ {
+ super(); // takes care of timestamp
+ }
+
+ /**
+ * constructor
+ * @param configuredCode Configured code for unmuting audio
+ */
+ public CTCSSMessage(CTCSSCode configuredCode)
+ {
+ super(); // takes care of timestamp
+ mConfiguredCode = configuredCode;
+ }
+
+ public CTCSSMessage(CTCSSCode configuredCode, String initialMessage, DecoderStateEvent.Event decoderEvent, SquelchCodeState codeState)
+ {
+ super(); // takes care of timestamp
+ mConfiguredCode = configuredCode;
+ mDebugMessage = initialMessage;
+ mCallEvent = decoderEvent;
+ mCodeState = codeState;
+ }
+
+ @Override
+ public String toString()
+ {
+ String toneString = mCTCSSCode != null ? mCTCSSCode.toString() : "None";
+ String mutedString = mMutedStatus ? "MUTED" : "UNMUTED";
+ String message = mDebugMessage != null ? mDebugMessage : "";
+
+ String s = MessageFormat.format("CTCSS: {0}, Config: {1}, Detected: {2}, 1st Thresh: {3}, Pwr: {4}, Pwr Thresh: {5}, {6}",
+ mutedString,
+ mConfiguredCode,
+ toneString,
+ mFirstThreshold,
+ String.format("%.1f", mPower),
+ String.format("%.1f", mPowerThreshold),
+ message
+ );
+ return s;
+ }
+
+ @Override
+ public boolean isValid()
+ {
+ return true;
+ }
+
+ @Override
+ public Protocol getProtocol()
+ {
+ return Protocol.CTCSS;
+ }
+
+ @Override
+ public List getIdentifiers()
+ {
+ if(mIdentifier != null)
+ {
+ return Collections.singletonList(mIdentifier);
+ }
+ return Collections.emptyList();
+ }
+
+ public void setCTCSSCode(CTCSSCode code)
+ {
+ mCTCSSCode = code;
+ mIdentifier = code != null ? new CTCSSIdentifier(code) : null;
+ }
+
+ public CTCSSCode getCTCSSCode()
+ {
+ return mCTCSSCode;
+ }
+
+ public void setMessage(String s)
+ {
+ mDebugMessage = s;
+ }
+
+ public void setMutedStatus(boolean b)
+ {
+ mMutedStatus = b;
+ }
+
+ public boolean getMutedStatus()
+ {
+ return mMutedStatus;
+ }
+
+ public void setFirstThreshold(boolean b)
+ {
+ mFirstThreshold = b;
+ }
+
+ public void setPower(double power)
+ {
+ mPower = power;
+ }
+
+ public void setPowerThreshold(double powerThreshold)
+ {
+ mPowerThreshold = powerThreshold;
+ }
+
+ public void setCodeState(SquelchCodeState state)
+ {
+ mCodeState = state;
+ }
+
+ public SquelchCodeState getCodeState()
+ {
+ return mCodeState;
+ }
+
+ public void setCallEvent(DecoderStateEvent.Event state)
+ {
+ mCallEvent = state;
+ }
+
+ public DecoderStateEvent.Event getCallEvent()
+ {
+ return mCallEvent;
+ }
+
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSCode.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSCode.java
similarity index 99%
rename from src/main/java/io/github/dsheirer/module/decode/dcs/DCSCode.java
rename to src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSCode.java
index 4ff4ac3f..dbb8000e 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSCode.java
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSCode.java
@@ -17,7 +17,7 @@
* ****************************************************************************
*/
-package io.github.dsheirer.module.decode.dcs;
+package io.github.dsheirer.module.decode.squelch.dcs;
import java.util.EnumSet;
import java.util.HashMap;
diff --git a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoder.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoder.java
similarity index 56%
rename from src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoder.java
rename to src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoder.java
index f2f535f7..754ccf95 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoder.java
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoder.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,8 +17,9 @@
* ****************************************************************************
*/
-package io.github.dsheirer.module.decode.dcs;
+package io.github.dsheirer.module.decode.squelch.dcs;
+import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
@@ -26,14 +27,17 @@ import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.remez.RemezFIRFilterDesigner;
import io.github.dsheirer.module.decode.Decoder;
import io.github.dsheirer.module.decode.DecoderType;
+import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.real.IRealBufferListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.Set;
+
/**
* Digital Coded Squelch (DCS) demodulator/decoder designed to work with FM demodulated 8 kHz input audio samples and
- * decode 134.4-baud signalling present in the unfiltered demodulated audio stream.
+ * decode 134.4-baud signaling present in the unfiltered demodulated audio stream.
*
* This decoder monitors the incoming sample stream looking for either a positive or negative sequence of samples where
* the slope of these values exceeds a threshold and causes the symbol state to flip. The symbol state remains across
@@ -43,6 +47,10 @@ import org.slf4j.LoggerFactory;
* designed to work with 8 kHz audio sample rate. Each baud period is (8,000 / 134.4) = 59.52 samples per symbol and
* a full DCS tone sequence is 23 bits * 59.52 samples/bit = 1369 samples. The DCS tone sequence is continuously
* transmitted at a rate of 134.4 bits-per-second / 23 = 5.84 repeats per second while the source continues transmitting.
+ *
+ * This decoder was modified to optionally run in inlineMode, when it is directly called from NBFM decoder for
+ * channel level DCS detection and muting. This determination is made during instantiation.
+ *
*/
public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
{
@@ -58,6 +66,10 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
private static final int IDEAL_SYMBOL_TRANSITION_MIN = 11;
private static final int IDEAL_SYMBOL_TRANSITION_MAX = 19;
private static final int CODE_MASK = 0x7FFFFF; //23-bit mask
+ private static final int LOSS_CODEWORDS = 3;
+ private static final int CONFIRMATION_COUNT = 1; // one confirming, unmutes on second decode
+ private static final int BIT_THRESHOLD_FOR_LOSS = 24; // one more than actually needed
+
private static float[] sLowPassFilterCoefficients;
private boolean mSymbol = false;
private float mBaudCounter = 0f;
@@ -65,6 +77,14 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
private float[] mResidual = new float[OVERLAP];
private int mCode = 0;
private int mExcessiveOneSequenceCounter = 0;
+ private boolean mInlineMode = true;
+ private Set mTargetCodes = null;
+ private int mConfirmationCounter = 0;
+ private int mCodewordsSinceMatch = 0;
+ private DCSCode mConfiguredCode = null;
+ private int mSamplesToSkip = 0;
+ private boolean mMuted = true;
+ private int mBitCounter = 0;
static
{
@@ -100,8 +120,20 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
/**
* Constructs an instance
*/
+ public DCSDecoder(Set targetCodes)
+ {
+ if(targetCodes != null && !targetCodes.isEmpty())
+ {
+ DCSCode configuredCode = targetCodes.iterator().next();
+ mConfiguredCode = DCSCode.hasValue(configuredCode.getValue()) ? configuredCode : DCSCode.UNKNOWN;
+ }
+ mTargetCodes = targetCodes;
+ mInlineMode = true;
+ }
+
public DCSDecoder()
{
+ mInlineMode = false;
}
/**
@@ -113,6 +145,11 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
return DecoderType.DCS;
}
+ public DCSCode getmConfiguredCode()
+ {
+ return mConfiguredCode;
+ }
+
/**
* Implementation of the IRealBufferListener interface
*/
@@ -121,19 +158,29 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
{
return this::receive;
}
-
/**
- * Processes the demodulated 8 kHz audio samples to extract the 300-baud LTR signalling and delivers the decoded
+ * Processes the demodulated 8 kHz audio samples to extract the 134.4-baud signaling and delivers the decoded
* symbol array to the registered listener.
* @param samples to demodulate.
*/
public void receive(float[] samples)
{
- if(getMessageListener() != null)
+ process(samples);
+ }
+
+ /**
+ * Process this audio buffer looking for a DCS code. It takes 2.6 512 sample-sized buffers to detecte a code.
+ * @param samples audio samples to decode (array size is 512)
+ * @return DCSMessage to notify a caller (usually NBFMDecoder) that a code was received or not for this audio buffer.
+ * Since it requires 2.6 buffers to detect/reject a code, this is usually null. Null indicates no state change.
+ */
+ public DCSMessage process(float[] samples)
+ {
+ DCSMessage inlineDCSMessage = null;
+ if(getMessageListener() != null || mInlineMode)
{
float[] filtered = mLowPassFilter.filter(samples);
float[] buffer = new float[filtered.length + OVERLAP];
- int samplesToSkip = 0;
int timingAdjust = 0;
float slope = 0;
@@ -146,9 +193,9 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
for(int bufferPointer = 0; bufferPointer < samples.length; bufferPointer++)
{
//Don't calculate slope if we're within the baud period following the last symbol transition
- if(samplesToSkip > 0)
+ if(mSamplesToSkip > 0)
{
- samplesToSkip--;
+ mSamplesToSkip--;
}
else
{
@@ -176,7 +223,7 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
}
mBaudCounter += timingAdjust;
- samplesToSkip = POST_TRANSITION_SAMPLES_TO_SKIP + timingAdjust;
+ mSamplesToSkip = POST_TRANSITION_SAMPLES_TO_SKIP + timingAdjust;
}
else if(slope < mMaxSlope)
{
@@ -205,7 +252,7 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
}
mBaudCounter += timingAdjust;
- samplesToSkip = POST_TRANSITION_SAMPLES_TO_SKIP + timingAdjust;
+ mSamplesToSkip = POST_TRANSITION_SAMPLES_TO_SKIP + timingAdjust;
}
else if(slope > mMaxSlope)
{
@@ -221,9 +268,34 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
{
//Update the code value adding in the current symbol
mCode = (Integer.rotateLeft(mCode, 1) + (mSymbol ? 1 : 0)) & CODE_MASK;
- if(DCSCode.hasValue(mCode))
+ if(!mInlineMode)
{
- getMessageListener().receive(new DCSMessage(DCSCode.fromValue(mCode), System.currentTimeMillis()));
+ if(DCSCode.hasValue(mCode))
+ {
+ DCSMessage detectionMessage = new DCSMessage(DCSCode.fromValue(mCode));
+ getMessageListener().receive(detectionMessage);
+ }
+ }
+ else
+ {
+ if(DCSCode.hasValue(mCode))
+ {
+ // a valid code has been received. Don't know if it's the one we're looking for
+ inlineDCSMessage = updateDetectionState(DCSCode.fromValue(mCode));
+ mBitCounter = 0;
+ }
+ else
+ {
+ if(mBitCounter < BIT_THRESHOLD_FOR_LOSS)
+ {
+ mBitCounter++;
+ }
+ else // No code has been detected in 24 bit periods, signal it's gone OR never received
+ {
+ inlineDCSMessage = updateDetectionState(null);
+ mBitCounter = 0;
+ }
+ }
}
mBaudCounter -= BAUD_LENGTH;
@@ -253,6 +325,7 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
mLog.warn("Unexpected error while processing DCS samples", e);
}
}
+ return inlineDCSMessage; // most of the time this is null, ignored if not inline mode
}
/**
@@ -290,4 +363,119 @@ public class DCSDecoder extends Decoder implements IRealBufferListener, Listener
return (float)(sumXY / SLOPE_CALCULATION_SUM_XX);
}
+
+ public void reset()
+ {
+ mSymbol = false;
+ mBaudCounter = 0;
+ mMaxSlope = 0;
+ mCode = 0;
+ mExcessiveOneSequenceCounter = 0;
+ mSamplesToSkip = 0;
+ mConfirmationCounter = 0;
+ mCodewordsSinceMatch = 0;
+ mResidual = new float[OVERLAP];
+ }
+
+ /**
+ * Called when noiseSquelch closes in inlineMode
+ * @return DCSMessage
+ */
+ public void inlineReset()
+ {
+ reset();
+ mMuted = true;
+ }
+
+ /**
+ * Handles detection logic of a decoded DCS code in inlineMode. This is called when there is a valid code
+ * or at least 23 bit periods have elapsed since the previous code, if any.
+ * @param newCode currently detected DCS code, or null if no code detected
+ * @return DCSMessage containing detected code, optional string message, call event info, muted status
+ */
+ private DCSMessage updateDetectionState(DCSCode newCode)
+ {
+ DCSMessage detectionMessage = new DCSMessage(mConfiguredCode);
+ if (mMuted)
+ {
+ if (newCode != null && mTargetCodes.contains(newCode))
+ {
+ if (mConfirmationCounter >= CONFIRMATION_COUNT)
+ {
+ // unmute and report detected code
+ mMuted = false;
+ detectionMessage.setMutedStatus(false);
+ detectionMessage.setDCSCode(newCode);
+ detectionMessage.setMessage("Correct DCS code detected, confirmation passed, now unmuting.");
+ detectionMessage.setCallEvent(DecoderStateEvent.Event.START);
+ detectionMessage.setCodeState(SquelchCodeState.ACCEPTED);
+ mCodewordsSinceMatch = 0;
+ }
+ else
+ {
+ // increment counter and wait for next detection
+ mConfirmationCounter++;
+ detectionMessage.setDCSCode(newCode);
+ detectionMessage.setMessage("Waiting for consecutive correct detections.");
+ }
+ }
+ else // wrong code or no code
+ {
+ mConfirmationCounter = 0;
+ detectionMessage.setMutedStatus(true);
+ detectionMessage.setDCSCode(newCode);
+ if(newCode != null)
+ {
+ detectionMessage.setCodeState(SquelchCodeState.REJECTED);
+ }
+ else
+ {
+ detectionMessage.setCodeState(SquelchCodeState.LOST);
+ }
+ }
+ }
+ else // unmuted and call is ongoing
+ {
+ if (newCode != null && mTargetCodes.contains(newCode))
+ {
+ // all is good, call continues
+ mCodewordsSinceMatch = 0;
+ detectionMessage.setMutedStatus(false);
+ detectionMessage.setDCSCode(newCode);
+ detectionMessage.setCallEvent(DecoderStateEvent.Event.CONTINUATION);
+ }
+ else // code is null or doesn't match desired code
+ {
+ if (mCodewordsSinceMatch >= LOSS_CODEWORDS)
+ {
+ // Mute audio and report rejected code or lost tone
+ mMuted = true;
+ detectionMessage.setMutedStatus(true);
+ detectionMessage.setDCSCode(newCode);
+ detectionMessage.setMessage("Incorrect DCS code or code lost, now muting.");
+ detectionMessage.setCallEvent(DecoderStateEvent.Event.END);
+ mConfirmationCounter = 0;
+ if(newCode != null)
+ {
+ detectionMessage.setCodeState(SquelchCodeState.REJECTED);
+ }
+ else
+ {
+ detectionMessage.setCodeState(SquelchCodeState.LOST);
+ }
+ }
+ else
+ {
+ // all is still good with the call, but a bad tone was decoded and codewordsSinceMatch hasn't
+ // reached a threshold yet.
+ mCodewordsSinceMatch++;
+ detectionMessage.setMutedStatus(false);
+ detectionMessage.setDCSCode(newCode);
+ detectionMessage.setMessage("Waiting for consecutive incorrect or no code.");
+ detectionMessage.setCallEvent(DecoderStateEvent.Event.CONTINUATION);
+ }
+ }
+ }
+ return detectionMessage;
+ }
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoderState.java
similarity index 98%
rename from src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoderState.java
rename to src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoderState.java
index 0fe3e639..4981f12b 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSDecoderState.java
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSDecoderState.java
@@ -17,7 +17,7 @@
* ****************************************************************************
*/
-package io.github.dsheirer.module.decode.dcs;
+package io.github.dsheirer.module.decode.squelch.dcs;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.channel.state.DecoderStateEvent;
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSEncode.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSEncode.java
new file mode 100644
index 00000000..bba4de9b
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSEncode.java
@@ -0,0 +1,58 @@
+package io.github.dsheirer.module.decode.squelch.dcs;
+
+/**
+ * converts a 9 - bit, 3 digit octal DCS code to a 23 bit binary word that can be utilized by the DCS decoder
+ * for non-standard DCS codes.
+ */
+
+public class DCSEncode
+{
+ // Parity check generator polynomial: x^11 + x^9 + x^7 + x^6 + x^5 + x + 1 = 0xC75
+ // In many implementations, it is written as 0xAE3 depending on bit ordering.
+ // Here we use the standard cyclic code generator polynomial 0xC75 for MSB-first CRC layout.
+ private static final int GENERATOR_POLY = 0xC75;
+
+
+ public static int OctStr2Int (String octalString)
+ {
+ return Integer.parseInt(octalString, 8) | 0x800;
+ }
+
+ /**
+ * Encodes a 12-bit message into a 23-bit systematic Golay codeword.
+ *
+ * @param message A 12-bit integer (0 to 4095).
+ * @return A 23-bit encoded integer, that has been bit-reversed for use in DCSDecoder.
+ */
+ public static int encode(int message)
+ {
+ // Enforce 12-bit bound
+ message &= 0xFFF;
+
+ // Compute the 11 golay bits using modulo-2 division (CRC style)
+ int remainder = message << 11;
+ for (int i = 22; i >= 11; i--) {
+ if ((remainder & (1 << i)) != 0) {
+ remainder ^= (GENERATOR_POLY << (i - 11));
+ }
+ }
+
+ // Combine the message bits and the calculated parity bits
+ int combinedMessage = ((remainder & 0x7ff) << 12) | message;
+
+ // reverse the order of the bits for DCSDecoder input -- LSB to MSB
+ int bitPosition = 0x400000; // 23rd bit position set
+ int reversedMessage = 0;
+ while(bitPosition != 0)
+ {
+ if((combinedMessage & 1) == 1)
+ {
+ reversedMessage = reversedMessage | bitPosition;
+ }
+ bitPosition = bitPosition >>> 1;
+ combinedMessage = combinedMessage >> 1;
+ }
+
+ return reversedMessage;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessage.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessage.java
new file mode 100644
index 00000000..2af38ad5
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessage.java
@@ -0,0 +1,179 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.squelch.dcs;
+
+import io.github.dsheirer.channel.state.DecoderStateEvent;
+import io.github.dsheirer.identifier.Identifier;
+import io.github.dsheirer.identifier.dcs.DCSIdentifier;
+import io.github.dsheirer.message.Message;
+import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
+import io.github.dsheirer.protocol.Protocol;
+
+import java.text.MessageFormat;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Digital Coded Squelch (DCS) tone detected message
+ */
+public class DCSMessage extends Message
+{
+ // null is a necessary default for many of these
+ private DCSCode mDCSCode = null;
+ private DCSCode mConfiguredCode = null;
+ private String mDebugMessage = null;
+ private DCSIdentifier mIdentifier = null;
+ private boolean mMutedStatus = true;
+ private DecoderStateEvent.Event mCallEvent = null;
+ private SquelchCodeState mCodeState = null;
+
+ /**
+ * Constructs an instance
+ * @param code that was detected
+ * @param timestamp when the code was detected
+ */
+ public DCSMessage(DCSCode code, long timestamp)
+ {
+ super();
+ mDCSCode = code;
+ }
+
+ /**
+ * Constructs an instance
+ * @param configuredCode that was detected
+ */
+ public DCSMessage(DCSCode configuredCode)
+ {
+ super(); // takes care of timestamp
+ mConfiguredCode = configuredCode;
+ }
+
+ /**
+ * Constructs an instance, usually at squelch close
+ * @param configuredCode that was detected
+ * @param initialMessage for debug or log
+ * @param callEvent for the initial DecoderStateEvent (usually END)
+ * @param codeState for the initial code state (usually LOST)
+ */
+ public DCSMessage(DCSCode configuredCode, String initialMessage, DecoderStateEvent.Event callEvent, SquelchCodeState codeState)
+ {
+ super(); // takes care of timestamp
+ mConfiguredCode = configuredCode;
+ mDebugMessage = initialMessage;
+ mCallEvent = callEvent;
+ mCodeState = codeState;
+ }
+
+ /**
+ * Constructs an instance
+ */
+ public DCSMessage()
+ {
+ super();
+ }
+
+ @Override
+ public String toString()
+ {
+ String DCSString = mDCSCode != null ? mDCSCode.toString() : "None";
+ String mutedString = mMutedStatus ? "MUTED" : "UNMUTED";
+ String message = mDebugMessage != null ? mDebugMessage : "";
+
+ String s = MessageFormat.format("DCS: {0}, Config: {1}, Detected: {2}, {3}",
+ mutedString,
+ mConfiguredCode,
+ DCSString,
+ message
+ );
+ return s;
+ }
+
+ /**
+ * The DCS code that was detected.
+ * @return code
+ */
+ public DCSCode getDCSCode()
+ {
+ return mDCSCode;
+ }
+
+ @Override
+ public boolean isValid()
+ {
+ return true; //We only send a message when the tone was valid
+ }
+
+ @Override
+ public Protocol getProtocol()
+ {
+ return Protocol.DCS;
+ }
+
+ @Override
+ public List getIdentifiers()
+ {
+ if(mIdentifier != null)
+ return Collections.singletonList(mIdentifier);
+ else
+ return Collections.emptyList();
+ }
+
+ public void setDCSCode(DCSCode code)
+ {
+ mDCSCode = code;
+ mIdentifier = code != null ? new DCSIdentifier(code) : null;
+ }
+
+ public void setMessage(String s)
+ {
+ mDebugMessage = s;
+ }
+
+ public void setMutedStatus(boolean b)
+ {
+ mMutedStatus = b;
+ }
+
+ public boolean getMutedStatus()
+ {
+ return mMutedStatus;
+ }
+
+ public void setCodeState(SquelchCodeState state)
+ {
+ mCodeState = state;
+ }
+
+ public SquelchCodeState getCodeState()
+ {
+ return mCodeState;
+ }
+
+ public void setCallEvent(DecoderStateEvent.Event state)
+ {
+ mCallEvent = state;
+ }
+
+ public DecoderStateEvent.Event getCallEvent()
+ {
+ return mCallEvent;
+ }
+
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessageFilter.java b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessageFilter.java
similarity index 95%
rename from src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessageFilter.java
rename to src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessageFilter.java
index af0812a8..ce78617c 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dcs/DCSMessageFilter.java
+++ b/src/main/java/io/github/dsheirer/module/decode/squelch/dcs/DCSMessageFilter.java
@@ -1,6 +1,6 @@
/*
* *****************************************************************************
- * Copyright (C) 2014-2023 Dennis Sheirer
+ * Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,7 @@
* ****************************************************************************
*/
-package io.github.dsheirer.module.decode.dcs;
+package io.github.dsheirer.module.decode.squelch.dcs;
import io.github.dsheirer.filter.Filter;
import io.github.dsheirer.filter.FilterElement;
diff --git a/src/main/java/io/github/dsheirer/protocol/Protocol.java b/src/main/java/io/github/dsheirer/protocol/Protocol.java
index 3a41ee24..f34e7468 100644
--- a/src/main/java/io/github/dsheirer/protocol/Protocol.java
+++ b/src/main/java/io/github/dsheirer/protocol/Protocol.java
@@ -30,6 +30,7 @@ public enum Protocol
APCO25_PHASE2("APCO-25 P2", "APCO25PHASE2", 12000),
ARS("ARS", "ARS", 0),
CELLOCATOR("CELLOCATOR", "CELLOCATOR", 0),
+ CTCSS("CTCSS", "CTCSS", 0),
DCS("DCS", "DCS", 134),
DMR("DMR", "DMR", 9600),
FLEETSYNC("Fleetsync", "FLEETSYNC", 1200),