diff --git a/gradle.properties b/gradle.properties index ce338574..546ede37 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ # # ****************************************************************************** -# Copyright (C) 2014-2024 Dennis Sheirer +# Copyright (C) 2014-2026 Dennis Sheirer # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/src/main/java/io/github/dsheirer/alias/AliasList.java b/src/main/java/io/github/dsheirer/alias/AliasList.java index 4d6ad25e..a3c0ac72 100644 --- a/src/main/java/io/github/dsheirer/alias/AliasList.java +++ b/src/main/java/io/github/dsheirer/alias/AliasList.java @@ -47,7 +47,7 @@ import io.github.dsheirer.identifier.talkgroup.FullyQualifiedTalkgroupIdentifier import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier; import io.github.dsheirer.identifier.tone.ToneIdentifier; import io.github.dsheirer.identifier.tone.ToneSequence; -import io.github.dsheirer.module.decode.dcs.DCSCode; +import io.github.dsheirer.module.decode.squelch.dcs.DCSCode; import io.github.dsheirer.protocol.Protocol; import java.util.ArrayList; import java.util.Collection; diff --git a/src/main/java/io/github/dsheirer/alias/id/dcs/Dcs.java b/src/main/java/io/github/dsheirer/alias/id/dcs/Dcs.java index c35c5eeb..da27b478 100644 --- a/src/main/java/io/github/dsheirer/alias/id/dcs/Dcs.java +++ b/src/main/java/io/github/dsheirer/alias/id/dcs/Dcs.java @@ -22,7 +22,7 @@ package io.github.dsheirer.alias.id.dcs; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import io.github.dsheirer.alias.id.AliasID; import io.github.dsheirer.alias.id.AliasIDType; -import io.github.dsheirer.module.decode.dcs.DCSCode; +import io.github.dsheirer.module.decode.squelch.dcs.DCSCode; /** * Digital Coded Squelch (DCS) tone identifier diff --git a/src/main/java/io/github/dsheirer/audio/AbstractAudioFilter.java b/src/main/java/io/github/dsheirer/audio/AbstractAudioFilter.java new file mode 100644 index 00000000..a760db40 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/AbstractAudioFilter.java @@ -0,0 +1,17 @@ +package io.github.dsheirer.audio; + +/** + * Used for defining an array list of filter methods to be implemented in AudioModule + * The filters will be applied in the order given in the list. + * Each audio filter mush implement the filter method. + */ + +public abstract class AbstractAudioFilter +{ + /** + * To be overridden by each audio filter + * @param audio a buffer of 8 KHz audio samples + * @return float[] filtered buffer of 8 KHz audio samples + */ + public abstract float[] filter(float[] audio); +} diff --git a/src/main/java/io/github/dsheirer/audio/AudioGainFilter.java b/src/main/java/io/github/dsheirer/audio/AudioGainFilter.java new file mode 100644 index 00000000..ac2601c9 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/AudioGainFilter.java @@ -0,0 +1,44 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.audio; + +import io.github.dsheirer.dsp.gain.AudioGainAndDcFilter; + +/** + * This is a wrapper for the AudioGainAndDcFilter class so it can be used with the AudioModule filter list. + * Usually instantiated in the DecoderFactory + */ +public class AudioGainFilter extends AbstractAudioFilter +{ + AudioGainAndDcFilter mAudioGain; + + public AudioGainFilter(float minGain, float maxGain, float objectiveAmplitude) + { + mAudioGain = new AudioGainAndDcFilter(minGain, maxGain, objectiveAmplitude); + mAudioGain.setDecayRate(2); // to remove pumping effect + mAudioGain.reset(); + } + + @Override + public float[] filter(float[] audio) + { + return mAudioGain.process(audio); + } +} diff --git a/src/main/java/io/github/dsheirer/audio/AudioModule.java b/src/main/java/io/github/dsheirer/audio/AudioModule.java index 29c17215..c4940fb5 100644 --- a/src/main/java/io/github/dsheirer/audio/AudioModule.java +++ b/src/main/java/io/github/dsheirer/audio/AudioModule.java @@ -32,6 +32,8 @@ import io.github.dsheirer.sample.real.IRealBufferListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; + /** * Provides packaging of demodulated audio sample buffers into audio segments for broadcast to registered listeners. * Includes audio packet metadata in constructed audio segments. @@ -42,36 +44,7 @@ public class AudioModule extends AbstractAudioModule implements ISquelchStateLis Listener { private static final Logger mLog = LoggerFactory.getLogger(AudioModule.class); - private static float[] sHighPassFilterCoefficients; - private final boolean mAudioFilterEnable; - - 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); + private ListmAudioFilters; private final SquelchStateListener mSquelchStateListener = new SquelchStateListener(); private SquelchState mSquelchState = SquelchState.SQUELCH; @@ -81,23 +54,23 @@ public class AudioModule extends AbstractAudioModule implements ISquelchStateLis * @param aliasList for aliasing identifiers * @param timeslot for this audio module * @param maxAudioSegmentLength in milliseconds - * @param audioFilterEnable to enable or disable high-pass audio filter + * @param audioFilters a list of audio filters to apply to the incoming audio samples */ - public AudioModule(AliasList aliasList, int timeslot, long maxAudioSegmentLength, boolean audioFilterEnable) + public AudioModule(AliasList aliasList, int timeslot, long maxAudioSegmentLength, List audioFilters) { super(aliasList, timeslot, maxAudioSegmentLength); - mAudioFilterEnable = audioFilterEnable; + mAudioFilters = audioFilters; } /** * Creates an Audio Module. * @param aliasList for aliasing identifiers - * @param audioFilterEnable to enable or disable high-pass audio filter + * @param audioFilters a list of audio filters to apply to the incoming audio samples */ - public AudioModule(AliasList aliasList, boolean audioFilterEnable) + public AudioModule(AliasList aliasList, List 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),