NBFM Channel level CTCSS/DCS and audio features (#2425)

NBFM decoder enhancements.  CTCSS and DCS squelch control.  Adds deemphasis, high pass filter control, and auto-level control
This commit is contained in:
Randy
2026-09-26 00:54:31 -06:00
committed by GitHub
parent 80360029ef
commit 97cae50bdd
34 changed files with 2377 additions and 217 deletions

View File

@@ -1,6 +1,6 @@
#
# ******************************************************************************
# Copyright (C) 2014-2024 Dennis Sheirer
# Copyright (C) 2014-2026 Dennis Sheirer
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.audio;
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);
}
}

View File

@@ -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<float[]>
{
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 List<AbstractAudioFilter>mAudioFilters;
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<AbstractAudioFilter> 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<AbstractAudioFilter> 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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<Integer> 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<DeemphasisMode> mDeemphasisCombo;
private ComboBox<SquelchDecoderConfig.SquelchType> mSquelchTypeCombo;
private ComboBox<CTCSSCode> mCtcssCodeCombo;
private ComboBox<DCSCode> 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<DeemphasisMode> 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<SquelchDecoderConfig> 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<SquelchDecoderConfig> 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);
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<CTCSSCode>
{
/**
* 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;
}
}

View File

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

View File

@@ -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<Module> modules, AliasList aliasList, DecodeConfiguration decodeConfig) {
modules.add(new PassportDecoder(decodeConfig));
modules.add(new PassportDecoderState());
modules.add(new AudioModule(aliasList, AUDIO_FILTER_ENABLE));
List<AbstractAudioFilter> 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<AbstractAudioFilter> 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<Module> modules, AliasList aliasList, DecodeConfigLTRNet decodeConfig) {
modules.add(new LTRNetDecoder(decodeConfig));
modules.add(new LTRNetDecoderState());
modules.add(new AudioModule(aliasList, AUDIO_FILTER_ENABLE));
List<AbstractAudioFilter> 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<AbstractAudioFilter> 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<AbstractAudioFilter> 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<AbstractAudioFilter> filterList = new ArrayList<>(); // no default filtering for AM
modules.add(new AudioModule(aliasList, 0, 60000, filterList));
}
else
{

View File

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

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<Identifier> getIdentifiers()
{
return Collections.singletonList(new DCSIdentifier(mDCSCode));
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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<SquelchDecoderConfig> 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<SquelchDecoderConfig> getSquelchDecoders()
{
return mSquelchDecoders;
}
public void setSquelchDecoders(List<SquelchDecoderConfig> 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<SquelchDecoderConfig> 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;
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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<ComplexSamples>, IRealBufferProvider, IDecoderStateEventProvider, INoiseSquelchController
@@ -67,6 +76,12 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
private Listener<DecoderStateEvent> mDecoderStateEventListener;
private RealResampler mResampler;
private final double mChannelBandwidth;
private boolean mSquelchDecoderEnabled = false;
private List<CTCSSCode> mConfiguredCTCSSCodes = new ArrayList<>();
private Set<DCSCode> 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<SquelchDecoderConfig> 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);
}
/**

View File

@@ -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<SquelchDecoderConfig> 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<String, int[]> 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<String, int[]> 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();
}
}
}
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<SquelchType> SQUELCH_TYPE ;
static
{
EnumSet<SquelchType> 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();
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<CTCSSCode> STANDARD_CODES;
public static final Set<CTCSSCode> DETECTING_CODES;
static
{
EnumSet<CTCSSCode> 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;
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<CTCSSCode> 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<CTCSSCode> 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<CTCSSCode> 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;
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<Identifier> 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;
}
}

View File

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

View File

@@ -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<float[]>
{
@@ -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<DCSCode> 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<DCSCode> 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;
}
}

View File

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

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<Identifier> 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;
}
}

View File

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

View File

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