#2494 P25P1 and NXDN Decoder Enhancements. (#2514)

- Updates P25P1 C4FM decoder to use same pattern developed for NXDN.
- Both share the C4FM symbol processor and equalizer.
- P25P1 updated with Garner style timing error detector
- NXDN filters updated to use a combined RRC and inverse sync filter all in one.
- P25P1 replaces RRC with band limiting filter
- P25P1 enhanced with dynamic PLL balance updates to combat multi-path fading, in addition to the sync detection training window.
- Decoder PPM error reports now only sent after 2x consecutive sync detections to prevent tuner PPM corrections from noisy/isolated false sync detects.
- Soft error correction added to both P25P1 and IDEN.

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-10-25 09:44:50 -04:00
committed by GitHub
parent df739f525f
commit 5635341d12
51 changed files with 4035 additions and 1504 deletions

View File

@@ -0,0 +1,354 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.c4fm;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.text.DecimalFormat;
import java.util.Arrays;
import org.apache.commons.math3.stat.descriptive.moment.Variance;
/**
* LMS adaptive equalizer for real-valued C4FM symbols. Trains taps on externally triggered sync detection combined
* with dynamic tap updates for a fixed symbol count and then freezes the taps until the error variance between sync
* detections exceeds a fixed threshold, whereby a tap retraining is enqueued for the next sync detection.
*/
public class C4FMEqualizer
{
private static final DecimalFormat DF = new DecimalFormat("0.0000");
//Maximum possible error is PI/4 = 0.78, but we clip it to less than half as the allowable error for tap updates
private static final float MAX_SYMBOL_ERROR = 0.3f;
private static final double TRAINING_IMPROVEMENT_GOAL_PER_ITERATION = 0.0005f;
private static final double TRAINING_CONVERGENCE_THRESHOLD = 0.16f;
private static final double TRAINING_SKIP_VARIANCE_THRESHOLD = 0.05;
private static final int TRAINING_ITERATIONS_MAX = 30;
private final Variance mVariance = new Variance();
private final float[] mBuffer;
private final float[] mReversedSyncSymbols;
private final float[] mTaps;
private final int mCenterTap;
private final int mDelaySymbolCount;
private final int mTrainingPeriodSymbolCount;
private final int mTrainingSkipCountThreshold;
private boolean mEnabled = false;
private boolean mSyncDetected = false;
private int mSymbolsUntilTraining = 0;
private int mTrainingSymbolsRemaining = 0;
private int mSkippedTrainingCount = 0;
/**
* Constructs an instance with an equalizer length derived from the sync pattern length.
*
* @param syncSymbols sync pattern that will be used to train the equalizer on sync detections
* @param trainingPeriodSymbolCount following sync detection to continue training.
* @param maxNoTrainSyncCount maximum number of consecutive sync detections and skipped trainings that can occur without retraining.
*/
public C4FMEqualizer(float[] syncSymbols, int trainingPeriodSymbolCount, int maxNoTrainSyncCount)
{
this(syncSymbols, getDefaultSymbolLength(syncSymbols.length), trainingPeriodSymbolCount, maxNoTrainSyncCount);
}
/**
* Constructs an instance
*
* @param syncSymbols sync pattern that will be used to train the equalizer on sync detections
* @param equalizerSymbolLength equalizer span in symbols. Even values are reduced by one so that the center tap
* has equal delay on either side. The tap count is twice this value since this is a fractional (T/2) equalizer.
* @param trainingPeriodSymbolCount following sync detection to continue training.
* @param maxNoTrainSyncCount maximum number of consecutive sync detections and skipped trainings that can occur without retraining.
*/
public C4FMEqualizer(float[] syncSymbols, int equalizerSymbolLength, int trainingPeriodSymbolCount,
int maxNoTrainSyncCount)
{
if(equalizerSymbolLength < 1)
{
throw new IllegalArgumentException("Equalizer symbol length must be at least 1");
}
//Store the sync pattern in reverse order
mReversedSyncSymbols = new float[syncSymbols.length];
for(int x = 0; x < syncSymbols.length; x++)
{
mReversedSyncSymbols[syncSymbols.length - x - 1] = syncSymbols[x];
}
//Make the symbol count odd length for equal delay on either side of the center tap
int symbolLength = (equalizerSymbolLength % 2 == 0) ? equalizerSymbolLength - 1 : equalizerSymbolLength;
mCenterTap = symbolLength; //This works since we're going to double the buffer length
mDelaySymbolCount = symbolLength / 2; //Delay until new symbol is aligned to center tap
mTaps = new float[symbolLength * 2];
mTaps[mCenterTap] = 1.0f;
int bufferLength = mReversedSyncSymbols.length * 2 + mTaps.length - 2;
mBuffer = new float[bufferLength];
mTrainingPeriodSymbolCount = trainingPeriodSymbolCount;
mTrainingSkipCountThreshold = maxNoTrainSyncCount;
}
/**
* Default equalizer span in symbols derived from the sync pattern length (odd length).
* @param syncLength in symbols
* @return default equalizer symbol length
*/
public static int getDefaultSymbolLength(int syncLength)
{
return (syncLength % 2 == 0) ? syncLength - 1 : syncLength;
}
/**
* Description of the current tap values
*/
public String getTapsDescription()
{
StringBuilder sb = new StringBuilder();
sb.append("<html>EQ Taps<br>");
for(int i = 0; i < mTaps.length; i++)
{
sb.append(i).append(": ").append(DF.format(mTaps[i])).append("<br>");
}
return sb.toString();
}
/**
* Symbol processing delay.
* @return processing delay in symbols.
*/
public int getDelaySymbolCount()
{
return mDelaySymbolCount;
}
/**
* Length of the equalizer in symbols. Note: the internal tap count is twice as long since this
* is a fractional (x2) equalizer.
* @return length in symbols.
*/
public int getLength()
{
return mTaps.length / 2;
}
/**
* Enable the equalizer when a valid sync pattern is detected
*/
public void enable()
{
mEnabled = true;
}
/**
* Indicates the enabled state.
* @return enabled state
*/
public boolean isEnabled()
{
return mEnabled;
}
/**
* Disable the equalizer when the signal is lost so that noise doesn't contaminate the equalizer taps.
*/
public void disable()
{
mEnabled = false;
}
/**
* Notifies the equalizer that a sync pattern is detected and queues a training sequence once a couple more
* symbols arrive once the equalizer delay line has aligned to output the sync pattern.
*/
public void syncDetected()
{
mSyncDetected = true;
mSymbolsUntilTraining = mDelaySymbolCount;
}
/**
* Indicates that the sync patter is lost and the equalizer should reset.
*/
public void syncLost()
{
// System.out.println("C4FM Equalizer - sync lost triggered");
reset();
disable();
}
/*
* Resets the equalizer taps
*/
public void reset()
{
Arrays.fill(mTaps, 0f);
mTaps[mCenterTap] = 1.0f;
mVariance.clear();
}
/**
* Process the samples and produce a delayed, equalized soft symbol ready for symbol decision. Updates the
* equalizer taps to correct for the error in the equalized sample relative to the hard symbol decision.
*
* @param fractional sample that falls halfway between symbols
* @param symbol to equalize
* @return equalized symbol.
*/
public float process(float fractional, float symbol)
{
//Shift buffer samples to the right two places and add new sample and symbol.
System.arraycopy(mBuffer, 0, mBuffer, 2, mBuffer.length - 2);
mBuffer[0] = fractional;
mBuffer[1] = symbol;
//Since there's a buffer delay, count down from sync detection until the pattern is aligned with the buffer
// contents and we can start training
if(mSyncDetected)
{
if(--mSymbolsUntilTraining <= 0)
{
train();
}
}
float equalized = 0f;
if(mEnabled)
{
int tapIndex;
for(tapIndex = 0; tapIndex < mTaps.length; tapIndex++)
{
equalized += mTaps[tapIndex] * mBuffer[tapIndex];
}
if(mTrainingSymbolsRemaining > 0)
{
mTrainingSymbolsRemaining--;
float error = Dibit.getError(equalized);
error = Math.clamp(error, -MAX_SYMBOL_ERROR, MAX_SYMBOL_ERROR);
//Step size (mu) in range 0.0001 to 0.01
float stepSize = 0.01f;
for(tapIndex = 0; tapIndex < mTaps.length; tapIndex++)
{
if(tapIndex != mCenterTap)
{
mTaps[tapIndex] += stepSize * error * mBuffer[tapIndex];
}
}
}
//Track the residual error that remains after equalization
mVariance.increment(Dibit.getError(equalized));
}
else
{
//Return the uncorrected, delayed symbol when we're disabled
equalized = mBuffer[mCenterTap];
}
return equalized;
}
/**
* Trains the equalizer against the buffered detected sync pattern. Trains against the sync pattern sequence
* multiple times while the per-iteration improvement continues to become smaller and stops training once the
* improvement falls below a threshold goal or exceeds a maximum iterations. If the training converges to
* below a threshold, we snapshot and use the trained taps, otherwise fallback to the previous training snapshot.
*/
private void train()
{
mSyncDetected = false;
//Skip retrain when we're still doing dynamic tap adjustments following the previous training session.
if(mTrainingSymbolsRemaining > 0)
{
mVariance.clear();
return;
}
// System.out.println("**C4FM EQ Training Triggered - skip count[" + mSkippedTrainingCount + "] variance [" + mVariance.getResult() + "] var threshold [" + TRAINING_SKIP_VARIANCE_THRESHOLD + "]");
//Ensure we've collected enough variance samples in the buffer and we have sufficient variance
if(mVariance.getN() <= mDelaySymbolCount || mVariance.getResult() > TRAINING_SKIP_VARIANCE_THRESHOLD ||
++mSkippedTrainingCount > mTrainingSkipCountThreshold)
{
mSkippedTrainingCount = 0;
//If the current variance tracked since the last training session using dynamic tap adjustments at each
// symbol is excessive, fallback to the most recent training snapshot as a better starting point
if(mVariance.getResult() > 0.225)
{
reset();
}
float accumulator, error;
double improvement, previousVariance;
int symbolIndex, index, trainingIterationCount = 0;
Variance variance = new Variance();
//Step size (mu) in range 0.0001 to 0.01
float stepSize = 0.005f;
do
{
previousVariance = variance.getN() == 0 ? 20.0 : variance.getResult();
variance.clear();
for(symbolIndex = 0; symbolIndex < mReversedSyncSymbols.length; symbolIndex++)
{
accumulator = 0.0f;
for(index = 0; index < mTaps.length; index++)
{
accumulator += mTaps[index] * mBuffer[index + (2 * symbolIndex)];
}
error = mReversedSyncSymbols[symbolIndex] - accumulator;
variance.increment(error);
for(index = 0; index < mTaps.length; index++)
{
if(index != mCenterTap)
{
mTaps[index] += error * stepSize * mBuffer[index + (2 * symbolIndex)];
}
}
}
trainingIterationCount++;
improvement = previousVariance - variance.getResult();
}
while(trainingIterationCount < TRAINING_ITERATIONS_MAX && improvement > TRAINING_IMPROVEMENT_GOAL_PER_ITERATION);
// System.out.println("**C4FM EQ - train complete - iterations [" + trainingIterationCount +
// "] end variance [" + DF.format(variance.getResult()) +
// "] final improvement [" + DF.format(improvement) +
// "] improvement threshold [" + DF.format(TRAINING_IMPROVEMENT_GOAL_PER_ITERATION) + "]");
//If we failed to converge, reset the taps to default and use blind training
if(variance.getResult() > TRAINING_CONVERGENCE_THRESHOLD)
{
reset();
}
mTrainingSymbolsRemaining = mTrainingPeriodSymbolCount;
}
mVariance.clear();
}
}

View File

@@ -0,0 +1,34 @@
package io.github.dsheirer.dsp.c4fm;
import io.github.dsheirer.dsp.symbol.Dibit;
/**
* C4FM message framer base class.
*/
public abstract class C4FMMessageFramer
{
/**
* Process the symbol
* @param symbol in radians
* @return true if the framer emits a valid message that includes this symbol, so that corrective measurement
* can be sent to a feedback decoder.
*/
public abstract boolean process(Dibit symbol);
/**
* Process the symbol decision along with the soft symbol it was decided from. Framers that can use soft symbols
* (ie soft decision decoding) override this method. Default implementation discards the soft symbol.
* @param symbol decision
* @param softSymbol equalized soft symbol in radians
* @return true if the framer emits a valid message that includes this symbol.
*/
public boolean process(Dibit symbol, float softSymbol)
{
return process(symbol);
}
/**
* A sync pattern is detected
*/
public abstract void syncDetected();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
package io.github.dsheirer.dsp.c4fm;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.util.Map;
import java.util.TreeMap;
import org.apache.commons.math3.analysis.interpolation.SplineInterpolator;
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction;
/**
* Base sync detector for C4FM/QPSK dibit implementations.
*/
public class DibitSyncDetector
{
private final long mPattern;
private final long mMask;
private final int mDibitLength;
private Dibit[] mPatternDibits;
private float[] mPatternSymbols;
private final Map<Integer, ReferenceSyncWaveform> mReferenceWaveformMap = new TreeMap<>();
/*
* Constructs an instance.
* @param pattern for the sync
* @param dibitLength of the sync pattern
*/
public DibitSyncDetector(long pattern, int dibitLength)
{
mPattern = pattern;
mMask = (long)(Math.pow(2, dibitLength * 2) - 1);
mDibitLength = dibitLength;
}
@Override
public String toString()
{
StringBuilder sb = new StringBuilder();
sb.append("SYNC: ").append(Long.toHexString(mPattern).toUpperCase());
sb.append(" MASK: ").append(Long.toHexString(mMask).toUpperCase());
sb.append(" LENGTH: ").append(mDibitLength);
return sb.toString();
}
/**
* Sync pattern
* @return pattern
*/
public long getPattern()
{
return mPattern;
}
/**
* Sync mask
*/
public long getMask()
{
return mMask;
}
/**
* Pattern length in dibits
*/
public int getDibitLength()
{
return mDibitLength;
}
/**
* Sync pattern as an array of dibits.
* @return dibit array.
*/
public Dibit[] getPatternDibits()
{
if(mPatternDibits == null)
{
mPatternDibits = toDibits(mPattern, mDibitLength);
}
return mPatternDibits;
}
/**
* Sync pattern as an array of soft symbols
* @return symbols array in radians.
*/
public float[] getPatternSymbols()
{
if(mPatternSymbols == null)
{
mPatternSymbols = toSymbols(getPatternDibits());
}
return mPatternSymbols;
}
/**
* Converts the sync pattern to dibits.
* @param pattern to convert
* @param dibitLength in dibits
* @return array of dibits for the sync pattern.
*/
public static Dibit[] toDibits(long pattern, int dibitLength)
{
Dibit[] dibits = new Dibit[dibitLength];
long mask = 3;
int dibitValue;
for(int x = 0; x < dibitLength; x++)
{
dibitValue = (int)((pattern & mask) >> (2 * x));
dibits[dibitLength - x - 1] = switch(dibitValue)
{
case 0 -> Dibit.D00_PLUS_1;
case 1 -> Dibit.D01_PLUS_3;
case 2 -> Dibit.D10_MINUS_1;
default -> Dibit.D11_MINUS_3;
};
mask = mask << 2;
}
return dibits;
}
/**
* Utility method to convert a dibit array into an array of ideal soft symbol values.
* @param dibits for the sync pattern
* @return ideal soft symbol array.
*/
public static float[] toSymbols(Dibit[] dibits)
{
float[] symbols = new float[dibits.length];
for(int x = 0; x < dibits.length; x++)
{
symbols[x] = dibits[x].getIdealPhase();
}
return symbols;
}
/**
* Creates a reference waveform representation of the sync pattern that can be used to correlate against a
* candidate detected sync waveform to discriminate between a valid and invalid sync detection. The generated
* waveform will be aligned where the final sample is time-aligned to the final symbol.
*
* @param samplesPerSymbol count
* @return a reference waveform and ideal correlation score.
*/
public ReferenceSyncWaveform getReferenceWaveform(double samplesPerSymbol)
{
int key = (int)(samplesPerSymbol * 1000.0);
if(mReferenceWaveformMap.containsKey(key))
{
return mReferenceWaveformMap.get(key);
}
float[] symbols = getPatternSymbols();
int sampleCount = (int)Math.ceil(samplesPerSymbol * (symbols.length - 1)) + 1;
float[] samples = new float[sampleCount];
double sampleInterval = 1.0 / samplesPerSymbol;
double[] x = new double[symbols.length + 2];
double[] y = new double[symbols.length + 2];
for(int i = -1; i < x.length - 1; i++)
{
x[i + 1] = i;
if(0 <= i && i < symbols.length)
{
y[i + 1] = symbols[i];
}
}
double start = symbols.length - 1;
int samplePointer = sampleCount - 1;
SplineInterpolator interpolator = new SplineInterpolator();
PolynomialSplineFunction function = interpolator.interpolate(x, y);
float accumulator = 0;
float sample;
float max = 0;
while(samplePointer >= 0)
{
sample = (float)function.value(start);
if(Math.abs(sample) > max)
{
max = Math.abs(sample);
}
samples[samplePointer--] = sample;
accumulator += sample * sample;
start -= sampleInterval;
}
ReferenceSyncWaveform referenceWaveform = new ReferenceSyncWaveform(samples, accumulator, max);
mReferenceWaveformMap.put(key, referenceWaveform);
return referenceWaveform;
}
}

View File

@@ -0,0 +1,40 @@
package io.github.dsheirer.dsp.c4fm;
import io.github.dsheirer.dsp.symbol.Dibit;
/**
* Interface for a soft sync detector for C4FM/QPSK dibits.
*/
public interface ISoftC4FMSyncDetector
{
/**
* Process the symbol and detect sync pattern
* @param softSymbol to process
* @return sync detection score
*/
float process(float softSymbol);
/**
* Soft symbols that represent the sync pattern
* @return soft symbols array
*/
float[] getPatternSymbols();
/**
* Array of dibits for the sync pattern
* @return sync pattern dibit array
*/
Dibit[] getPatternDibits();
/**
* Length of the sync pattern in dibits
*/
int getDibitLength();
/**
* Provides a reference waveform that represents the sync pattern for sync detection validation
* @param samplesPerSymbol for each symbol
* @return reference waveform
*/
ReferenceSyncWaveform getReferenceWaveform(double samplesPerSymbol);
}

View File

@@ -0,0 +1,28 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.c4fm;
/**
* Waveform representing the ideal sync pattern, resampled at the sample rate.
* @param samples in samples
* @param idealCorrelationScore as dot-product of the ideal multiplied by itself.
* @param maxSample maximum allowable sample value
*/
public record ReferenceSyncWaveform(float[] samples, float idealCorrelationScore, float maxSample){};

View File

@@ -607,6 +607,63 @@ public class FilterFactory
return cicArray;
}
/**
* Designs an integrate-and-dump filter.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second (4800 for P25 Phase 1)
* @return filter taps, symmetric, summing to 1.0 (unity DC gain)
* @throws IllegalArgumentException if either rate is non-positive or the sample rate is below the symbol rate
*/
public static float[] getIntegrateAndDump(double sampleRate, double symbolRate)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
double samplesPerSymbol = sampleRate / symbolRate;
if(samplesPerSymbol < 1.0)
{
throw new IllegalArgumentException("Sample rate must be at least the symbol rate");
}
double halfWidth = samplesPerSymbol / 2.0;
//Candidate length: odd and long enough to span the window plus a partial tap on each edge
int length = 2 * (int)Math.ceil(halfWidth + 0.5) + 1;
double center = (length - 1) / 2.0;
double[] weights = new double[length];
for(int i = 0; i < length; i++)
{
//Each tap represents the unit interval [x - 0.5, x + 0.5] centered on its position
double x = i - center;
double low = Math.max(x - 0.5, -halfWidth);
double high = Math.min(x + 0.5, halfWidth);
weights[i] = Math.max(0.0, high - low);
}
//Trim zero-weight taps from the edges (symmetric, so trim equally)
final double epsilon = 1e-9;
int start = 0;
while(start < length / 2 && weights[start] < epsilon)
{
start++;
}
int end = length - start;
//Normalize to unity DC gain: the weights sum to samplesPerSymbol
float[] taps = new float[end - start];
for(int i = start; i < end; i++)
{
taps[i - start] = (float)(weights[i] / samplesPerSymbol);
}
return taps;
}
/**
* Creates a Root Raised Cosine filter.
* @param samplesPerSymbol