#2518 P25P1 LSM Decoder enhancements. (#2523)

* Uses same shared architecture and symbol processor pattern as other C4FM decoders.
* Adds soft symbol processing and sync detection.
* Removes RRC filter and replaces with improved channel filter.
* Viterbi soft symbol decoding with demodulator reliability weighting
* Improved sync reacquisition in low SNR
* Improved handling for phase wrapping and interpolation
* NAC Tracker updated to track by NAC count
* Native buffers returning partial buffers
* Message processor uses only valid LDU1 words
* Reduced phantom TDU messages
* Tuner/Channe frequency error corrections don't reset the symbol trackers and fix possible divide by zero error
* LSM timing error detector now updates per symbol block size instead of sample buffer size - faster convergence
* New unit tests
* Resolves several issues, including a lagging sync detect offset issue in the recently updated P25P1 C4FM decoder.
* Consolidate filter designers for each protocol back into the FilterFactory class.
* DMR, P25 Phase 1 C4FM and LSM, and P25 Phase 2 decoders design the baseband filter once and share the taps across the I and Q filters.

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-10-29 04:17:47 -04:00
committed by GitHub
parent f2c98cdb40
commit 6b9cd2d317
42 changed files with 3586 additions and 2767 deletions

View File

@@ -100,8 +100,10 @@ public class FloatNativeBuffer extends AbstractNativeBuffer
throw new IllegalStateException("No more samples");
}
float[] chunk = Arrays.copyOfRange(mInterleavedComplexSamples, mBufferPointer, mBufferPointer + BUFFER_SIZE * 2);
mBufferPointer += BUFFER_SIZE * 2;
//The final chunk holds the remaining samples, so that it isn't padded with zero valued samples
int end = Math.min(mBufferPointer + BUFFER_SIZE * 2, mInterleavedComplexSamples.length);
float[] chunk = Arrays.copyOfRange(mInterleavedComplexSamples, mBufferPointer, end);
mBufferPointer = end;
return SampleUtils.deinterleave(chunk, getTimestamp());
}
@@ -125,8 +127,10 @@ public class FloatNativeBuffer extends AbstractNativeBuffer
throw new IllegalStateException("No more samples");
}
float[] chunk = Arrays.copyOfRange(mInterleavedComplexSamples, mBufferPointer, mBufferPointer + BUFFER_SIZE * 2);
mBufferPointer += BUFFER_SIZE * 2;
//The final chunk holds the remaining samples, so that it isn't padded with zero valued samples
int end = Math.min(mBufferPointer + BUFFER_SIZE * 2, mInterleavedComplexSamples.length);
float[] chunk = Arrays.copyOfRange(mInterleavedComplexSamples, mBufferPointer, end);
mBufferPointer = end;
return new InterleavedComplexSamples(chunk, getTimestamp());
}

View File

@@ -47,6 +47,20 @@ public abstract class C4FMMessageFramer
return process(symbol);
}
/**
* Process the symbol decision along with the soft symbol it was decided from and the soft symbol reliability (ie
* inverse of the soft symbol noise variance). Framers that can weight soft decision decoding override this method.
* Default implementation discards the reliability.
* @param symbol decision
* @param softSymbol equalized soft symbol in radians
* @param reliability of the soft symbol
* @return true if the framer emits a valid message that includes this symbol.
*/
public boolean process(Dibit symbol, float softSymbol, float reliability)
{
return process(symbol, softSymbol);
}
/**
* A sync pattern is detected
*/

View File

@@ -75,10 +75,17 @@ public abstract class C4FMSymbolProcessor
private static final double IDEAL_OUTER_SYMBOL_PHASE = 3.0 * Math.PI / 4.0;
//Allowable gain deviation from the nominal gain, as a fraction of the nominal gain.
private static final double SAMPLE_GAIN_TOLERANCE = 0.1;
//Reliability delay line length, which must exceed the symbol equalizer delay.
private static final int RELIABILITY_DELAY_LINE_LENGTH = 32;
private double mNoiseStandardDeviationThreshold;
private double mSamplePoint;
protected double mSamplesPerSymbol;
protected float[] mBuffer;
//Optional soft symbol reliabilities for each sample, parallel to the sample buffer (see process(float[], float[]))
private float[] mReliabilityBuffer;
//Delays the sampled reliabilities to align with the soft symbols emerging from the symbol equalizer delay line
private final float[] mReliabilityDelayLine = new float[RELIABILITY_DELAY_LINE_LENGTH];
private int mReliabilityDelayLinePointer = 0;
private float mLaggingSyncOffset;
protected float mOptimizeFineIncrement;
private int mBufferPointer;
@@ -216,6 +223,16 @@ public abstract class C4FMSymbolProcessor
*/
public abstract float getSyncThresholdEqualized();
/**
* Minimum correlation against the reference sync waveform, as a fraction of the ideal correlation score, for the
* first two sync detections following a sync loss (see validateSyncWaveform()). Default is 85%.
* @return validation threshold as a fraction of the ideal correlation score.
*/
public double getSyncWaveformValidationThreshold()
{
return 0.85;
}
/**
* Notification that a sync detection was validated and the correction applied. Subclasses can override this to
* capture protocol specific details (ie the detected sync pattern) from the sync detector that triggered the
@@ -269,6 +286,19 @@ public abstract class C4FMSymbolProcessor
* @param samples to process
*/
public void process(float[] samples)
{
process(samples, null);
}
/**
* Primary input method for receiving a stream of demodulated samples, with an optional parallel stream of soft
* symbol reliabilities (ie inverse of the soft symbol noise variance) for each sample, to process into symbols.
* When provided, each soft symbol's reliability is sampled at the symbol sampling instant and is provided to the
* message framer with the soft symbol to weight soft decision decoding.
* @param samples to process
* @param reliabilities for each sample, or null.
*/
public void process(float[] samples, float[] reliabilities)
{
//Establish buffer size based on incoming sample array length which should be consistent across decoder lifecycle
if(mBuffer == null)
@@ -278,6 +308,11 @@ public abstract class C4FMSymbolProcessor
mBufferPointer = mBufferReloadThreshold;
}
if(reliabilities != null && mReliabilityBuffer == null)
{
mReliabilityBuffer = new float[mBuffer.length];
}
//Create shadow copy of heap variables on stack
double samplePoint = mSamplePoint;
double samplesPerSymbol = mSamplesPerSymbol;
@@ -319,6 +354,22 @@ public abstract class C4FMSymbolProcessor
int copyLength = samples.length;
System.arraycopy(mBuffer, copyLength, mBuffer, 0, mBuffer.length - copyLength);
System.arraycopy(samples, samplesPointer, mBuffer, mBuffer.length - copyLength, copyLength);
if(mReliabilityBuffer != null)
{
System.arraycopy(mReliabilityBuffer, copyLength, mReliabilityBuffer, 0, mBuffer.length - copyLength);
if(reliabilities != null && reliabilities.length == samples.length)
{
System.arraycopy(reliabilities, samplesPointer, mReliabilityBuffer, mBuffer.length - copyLength,
copyLength);
}
else
{
Arrays.fill(mReliabilityBuffer, mBuffer.length - copyLength, mBuffer.length, Float.NaN);
}
}
samplesPointer += copyLength;
bufferPointer -= copyLength;
}
@@ -352,6 +403,13 @@ public abstract class C4FMSymbolProcessor
softSymbol = mSampleEqualizer.getEqualizedSymbol(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], samplePoint);
float timingSymbol = softSymbol;
if(mReliabilityBuffer != null)
{
mReliabilityDelayLinePointer = (mReliabilityDelayLinePointer + 1) % RELIABILITY_DELAY_LINE_LENGTH;
mReliabilityDelayLine[mReliabilityDelayLinePointer] = (float)(mReliabilityBuffer[bufferPointer] +
(mReliabilityBuffer[bufferPointer + 1] - mReliabilityBuffer[bufferPointer]) * samplePoint);
}
//Process dynamic updates to the PLL when supported by the decoder
softSymbol = mSampleEqualizer.correct(softSymbol);
@@ -413,7 +471,21 @@ public abstract class C4FMSymbolProcessor
// 2 consecutive sync detections, send the current sample equalizer's balance value as a pseudo-PLL
// error measurement so the tuner can self adjust to correct PPM. The balance is applied in the
// equalized symbol domain, so divide by gain to convert to radians per sample for the decoder.
if(mMessageFramer.process(symbol, softSymbol) && mConsecutiveSyncPeriods > 1)
boolean validMessage;
if(mReliabilityBuffer != null)
{
int delay = Math.min(mSymbolEqualizer.getDelaySymbolCount(), RELIABILITY_DELAY_LINE_LENGTH - 1);
float reliability = mReliabilityDelayLine[(mReliabilityDelayLinePointer - delay +
RELIABILITY_DELAY_LINE_LENGTH) % RELIABILITY_DELAY_LINE_LENGTH];
validMessage = mMessageFramer.process(symbol, softSymbol, reliability);
}
else
{
validMessage = mMessageFramer.process(symbol, softSymbol);
}
if(validMessage && mConsecutiveSyncPeriods > 1)
{
mFeedbackDecoder.processPLLError(mSampleEqualizer.balanceOptimized() / mSampleEqualizer.gain());
}
@@ -542,14 +614,14 @@ public abstract class C4FMSymbolProcessor
for(int x = referenceSyncWaveform.samples().length - 1; x >= 0; x--)
{
index = integral + x;
float sample = LinearInterpolator.calculate(mBuffer[index], mBuffer[index + 1], mu);
float sample = interpolate(mBuffer[index], mBuffer[index + 1], mu);
sample *= gain;
sample += balance;
sample = Math.clamp(sample, -referenceSyncWaveform.maxSample(), referenceSyncWaveform.maxSample());
accumulator += referenceSyncWaveform.samples()[x] * sample;
}
double threshold = referenceSyncWaveform.idealCorrelationScore() * 0.85; //85% of the ideal correlation score
double threshold = referenceSyncWaveform.idealCorrelationScore() * getSyncWaveformValidationThreshold();
//Return the correction as valid if the correlation score exceeds the threshold, otherwise return the static
// invalid sync detection to nullify the correction.
@@ -1145,7 +1217,7 @@ public abstract class C4FMSymbolProcessor
}
/**
* Equalizes both samples with optional phase wrap detection and returns the interpolated value at the mu offset.
* Interpolates between the samples at the mu offset and equalizes the interpolated value.
*
* @param sample1 of the sample stream
* @param sample2 of the sample stream
@@ -1154,10 +1226,7 @@ public abstract class C4FMSymbolProcessor
*/
public float getEqualizedSymbol(float sample1, float sample2, double mu)
{
sample1 = equalize(sample1);
sample2 = equalize(sample2);
//Calculate interpolated sample and pass through the dynamic balance correction method.
return LinearInterpolator.calculate(sample1, sample2, mu);
return equalize(interpolate(sample1, sample2, mu));
}
/**
@@ -1179,6 +1248,21 @@ public abstract class C4FMSymbolProcessor
}
/**
* Interpolates the (unequalized) sample value between two consecutive samples of the demodulated sample stream.
* Default implementation is linear interpolation. Protocols with a phase wrapped sample stream (ie P25 LSM) can
* override this to interpolate across the phase wrap.
*
* @param sample1 of the sample stream
* @param sample2 of the sample stream
* @param mu interpolation point between sample 1 and sample 2, in the range 0.0 to 1.0
* @return interpolated sample
*/
protected float interpolate(float sample1, float sample2, double mu)
{
return LinearInterpolator.calculate(sample1, sample2, mu);
}
/**
* Calculates the sync correlation score at the specified offset and samples per symbol interval using the
* current equalizer gain and supplied balance value.
@@ -1209,16 +1293,13 @@ public abstract class C4FMSymbolProcessor
double fractional = pointer - bufferPointer;
float score = 0;
float sample1, sample2;
float idealSymbol;
for(int x = 0; x < patternSymbols.length; x++)
{
if(bufferPointer < maxPointer)
{
sample1 = mBuffer[bufferPointer] * gain + balance;
sample2 = mBuffer[bufferPointer + 1] * gain + balance;
softSymbol = LinearInterpolator.calculate(sample1, sample2, fractional);
softSymbol = interpolate(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], fractional) * gain + balance;
}
else
{
@@ -1309,8 +1390,8 @@ public abstract class C4FMSymbolProcessor
if(resampleStartIntegral >= 0)
{
symbol = patternSymbols[x];
resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[resampleStartIntegral],
mBuffer[resampleStartIntegral + 1], resampleStart - resampleStartIntegral);
resampledSoftSymbol = interpolate(mBuffer[resampleStartIntegral], mBuffer[resampleStartIntegral + 1],
resampleStart - resampleStartIntegral);
resampledSoftSymbol = resampledSoftSymbol * mSampleEqualizer.mGain + measuredBalance;
resampledDibit = Dibit.fromSample(resampledSoftSymbol);
bitErrorCount += patternDibits[x].getBitErrorFrom(resampledDibit);

View File

@@ -57,8 +57,11 @@ import io.github.dsheirer.vector.calibrate.CalibrationManager;
import io.github.dsheirer.vector.calibrate.CalibrationType;
import io.github.dsheirer.vector.calibrate.Implementation;
import java.text.DecimalFormat;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.math3.util.FastMath;
import org.jtransforms.fft.FloatFFT_1D;
import org.slf4j.Logger;
@@ -72,6 +75,7 @@ public class FilterFactory
private final static Logger mLog = LoggerFactory.getLogger(FilterFactory.class);
private static final double PERFECT_RECONSTRUCTION_GAIN_AT_BAND_EDGE = -6.020599842071533; //decibel(0.5, 0.0)
private static final double MARGIN_OF_ERROR = 0.0003;
private static final Map<LowPassSpecification,float[]> REMEZ_LOW_PASS_FILTERS = new ConcurrentHashMap<>();
/**
* Generates coefficients for a unity-gain, windowed low-pass filter
@@ -664,111 +668,6 @@ public class FilterFactory
return taps;
}
/**
* Creates a Root Raised Cosine filter.
* @param samplesPerSymbol
* @param symbolCount
* @param alpha
* @return
*/
public static float[] getRRC(double samplesPerSymbol, int symbolCount, float alpha)
{
//TODO: this wraps the existing method which is reducing the samples per symbol value x2 internally and is likely
// a mistake. This method is used by NXDN to correct that error until the DMR and P25 decoders can be updated to
// ensure that the correction doesn't impact those decoders. Once the cleanup is complete, this method can be
// deleted and the NXDN implementation can revert to using the future corrected version.
return getRootRaisedCosine(samplesPerSymbol * 2, symbolCount, alpha);
}
/**
* Creates root raised cosine filter coefficients with a tap count equal to the symbols x samplesPerSymbol + 1.
*
* Symbol count should be an even integer.
* Ported to java from gnuradio/filter/firdes.cc
*
* For 40db attenuation, calculate the number of symbols based on the following formula:
* Symbols = -44 * alpha + 33
*
* Polyphase Channelizer notes:
* -Set samples Per Symbol at 2 (or more) * number of channels
* -Set symbolCount to sufficient size to produce required attenuation
* -Set symbol rate in hertz
* -Set alpha = (desired channel bandwidth / (symbol rate * samples per symbol) - 1.0
* -Channel bandwidth must be larger than symbol rate * samples per symbol, in order for there to be a positive alpha
*
* @param samplesPerSymbol - number of samples per symbol
* @param symbolCount - number of symbol intervals for the filter. This directly impacts the filter size
* @param alpha - roll-off factor.
* @param gain of the filter
* @return - filter coefficients
*/
public static float[] getRootRaisedCosine(double samplesPerSymbol, int symbolCount, float alpha)
{
samplesPerSymbol /= 2;
int taps = (int)Math.round(samplesPerSymbol * symbolCount);
float scale = 0;
float[] coefficients = new float[taps];
for(int x = 0; x < taps; x++)
{
double index = x - (taps / 2.0f);
double x1 = FastMath.PI * index / samplesPerSymbol;
double x2 = 4.0 * alpha * index / samplesPerSymbol;
double x3 = x2 * x2 - 1.0;
double numerator, denominator;
if(FastMath.abs(x3) >= 0.000001)
{
if(x != taps / 2)
{
numerator = FastMath.cos((1.0 + alpha) * x1) +
FastMath.sin((1.0 - alpha) * x1) / (4.0 * alpha * index / samplesPerSymbol);
}
else
{
numerator = FastMath.cos((1.0 + alpha) * x1) + (1.0 - alpha) * FastMath.PI / (4.0 * alpha);
}
denominator = x3 * FastMath.PI;
}
else
{
if(alpha == 1.0)
{
coefficients[x] = -1.0f;
continue;
}
x3 = (1.0 - alpha) * x1;
x2 = (1.0 + alpha) * x1;
numerator = (FastMath.sin(x2) * (1.0 + alpha) * FastMath.PI - FastMath.cos(x3) * ((1.0 - alpha) * FastMath.PI *
samplesPerSymbol) / (4.0 * alpha * index) + FastMath.sin(x3) * samplesPerSymbol *
samplesPerSymbol / (4.0 * alpha * index * index));
denominator = -32.0 * FastMath.PI * alpha * alpha * index / samplesPerSymbol;
}
coefficients[x] = (float)(4.0 * alpha * numerator / denominator);
scale += coefficients[x];
}
/**
* Normalize (scale) coefficients to 1.0 sum and apply gain
*/
for(int x = 0; x < taps; x++)
{
coefficients[x] = coefficients[x] / scale;
}
return coefficients;
}
/**
* Creates a filter from the filter specification using the remez exchange design algorithm
*
@@ -788,6 +687,572 @@ public class FilterFactory
return null;
}
/**
* Creates a low pass filter using the remez exchange design algorithm with 0.01 dB pass band and stop band ripple.
* See getRemezLowPass(double, double, double, double, double).
*
* @param sampleRate in Hertz
* @param passBandCutoff in Hertz
* @param stopBandStart in Hertz
* @return filter coefficients
* @throws IllegalStateException if the filter cannot be designed
*/
public static float[] getRemezLowPass(double sampleRate, double passBandCutoff, double stopBandStart)
{
return getRemezLowPass(sampleRate, passBandCutoff, 0.01, stopBandStart, 0.01);
}
/**
* Creates a unity gain low pass filter using the remez exchange design algorithm. Designed filters are cached and
* reused, since the design is computationally expensive and decoders request the same filters each time they start.
*
* @param sampleRate in Hertz
* @param passBandCutoff in Hertz
* @param passBandRipple in decibels
* @param stopBandStart in Hertz
* @param stopBandRipple in decibels
* @return filter coefficients, a copy that the caller can modify (ie RealFIRFilter reverses its coefficients)
* @throws IllegalStateException if the filter cannot be designed
*/
public static float[] getRemezLowPass(double sampleRate, double passBandCutoff, double passBandRipple,
double stopBandStart, double stopBandRipple)
{
LowPassSpecification key = new LowPassSpecification(sampleRate, passBandCutoff, passBandRipple, stopBandStart,
stopBandRipple);
//Filter cache is shared across decoder instances that may be started from different threads
float[] coefficients = REMEZ_LOW_PASS_FILTERS.get(key);
if(coefficients == null)
{
FIRFilterSpecification specification = FIRFilterSpecification
.lowPassBuilder()
.sampleRate(sampleRate)
.passBandCutoff(passBandCutoff)
.passBandAmplitude(1.0).passBandRipple(passBandRipple)
.stopBandAmplitude(0.0).stopBandStart(stopBandStart)
.stopBandRipple(stopBandRipple).build();
try
{
coefficients = getTaps(specification);
if(coefficients != null)
{
REMEZ_LOW_PASS_FILTERS.put(key, coefficients);
}
}
catch(Exception e) //FilterDesignException or runtime failures from the Remez designer
{
mLog.error("Error designing low pass filter for " + key, e);
}
}
if(coefficients == null)
{
throw new IllegalStateException("Unable to design low pass filter for " + key);
}
return Arrays.copyOf(coefficients, coefficients.length);
}
/**
* Remez low pass filter design parameters, used as the filter cache key.
*/
private record LowPassSpecification(double sampleRate, double passBandCutoff, double passBandRipple,
double stopBandStart, double stopBandRipple)
{
@Override
public String toString()
{
return "sample rate [" + sampleRate + "] pass band [" + passBandCutoff + "] ripple [" + passBandRipple +
"] stop band [" + stopBandStart + "] ripple [" + stopBandRipple + "]";
}
}
/**
* Designs an inverse sinc (x / sin(x)) filter that compensates for the sinc (integrate and dump) response of a
* transmitter's shaping filter (ie NXDN). The response is inverse sinc up to the band edge of the companion root
* raised cosine filter at (1 + alpha) * symbolRate / 2, and is held at the band edge gain above it.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor of the companion RRC filter, defining the band edge (1 + alpha) * symbolRate / 2
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length (minimum 9), linear phase, unity gain at DC
*/
public static float[] getInverseSincFilter(double sampleRate, double symbolRate, double alpha, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(alpha < 0 || alpha > 1)
{
throw new IllegalArgumentException("Alpha must be in the range 0 - 1");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
double bandEdge = (1.0 + alpha) * symbolRate / 2.0;
if(bandEdge >= sampleRate / 2.0)
{
throw new IllegalArgumentException("Sample rate [" + sampleRate +
"] is too low for symbol rate [" + symbolRate + "] - band edge exceeds Nyquist");
}
int length = Math.max(getOddLength(symbolSpan * sampleRate / symbolRate), 9);
double symbolPeriod = 1.0 / symbolRate;
double edgeGain = inverseSinc(bandEdge * symbolPeriod);
//Least-squares weight applied beyond the band edge, relative to a weight of 1.0 in band.
double stopBandWeight = 0.01;
double[] taps = getLeastSquaresSymmetricTaps(sampleRate, length, frequency -> frequency <= bandEdge ?
new double[]{inverseSinc(frequency * symbolPeriod), 1.0} : new double[]{edgeGain, stopBandWeight});
return normalizeToUnityDC(taps);
}
/**
* Designs a root raised cosine filter from the closed form impulse response, normalized to unity gain at DC.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor (0 - 1]
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length, linear phase
*/
public static float[] getRootRaisedCosineFilter(double sampleRate, double symbolRate, double alpha, int symbolSpan)
{
double[] taps = getRootRaisedCosineTaps(sampleRate, symbolRate, alpha, symbolSpan);
float[] output = new float[taps.length];
for(int n = 0; n < taps.length; n++)
{
output[n] = (float)taps[n];
}
return output;
}
/**
* Designs a receive filter that is a root raised cosine filter convolved with an inverse sinc filter (see
* getInverseSincFilter()). Cascaded with a transmit filter that is a root raised cosine and sinc filter (ie NXDN),
* the overall response is a raised cosine Nyquist filter with no intersymbol interference at the symbol sampling
* instants.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor (0 - 1]
* @param rrcSymbolSpan root raised cosine filter length in symbol periods
* @param inverseSincSymbolSpan inverse sinc filter length in symbol periods
* @return filter taps, odd length (sum of both filter lengths - 1), linear phase, unity gain at DC
*/
public static float[] getRootRaisedCosineInverseSincFilter(double sampleRate, double symbolRate, double alpha,
int rrcSymbolSpan, int inverseSincSymbolSpan)
{
double[] rrc = getRootRaisedCosineTaps(sampleRate, symbolRate, alpha, rrcSymbolSpan);
float[] inverseSinc = getInverseSincFilter(sampleRate, symbolRate, alpha, inverseSincSymbolSpan);
//Full linear convolution - both filters are odd length and symmetric, so the result is too
double[] combined = new double[rrc.length + inverseSinc.length - 1];
for(int i = 0; i < rrc.length; i++)
{
for(int j = 0; j < inverseSinc.length; j++)
{
combined[i + j] += rrc[i] * inverseSinc[j];
}
}
//Normalize to unity gain at DC so the discriminator output keeps its deviation scaling
return normalizeToUnityDC(combined);
}
/**
* Designs a band limited integrate and dump receive filter for the FM discriminator output (ie P25 Phase 1 C4FM).
*
* TIA-102.BAAA specifies a raised cosine (alpha 0.2) Nyquist filter cascaded with an inverse sinc (x / sin(x))
* shaping filter at the transmitter, and an integrate and dump filter (sinc response) at the receiver, so that the
* overall response is the raised cosine with no intersymbol interference at the symbol sampling instants.
*
* The transmitted signal is band limited to (1 + alpha) * symbolRate / 2, but an integrate and dump filter has
* slowly decaying sidelobes that pass the FM discriminator noise above the signal band, where the discriminator
* noise power spectral density rises with the square of frequency. This produces a linear phase FIR filter with
* the integrate and dump (sinc) response across the signal band and a stopband above it, which preserves the zero
* ISI property while rejecting the out of band noise.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor of the transmitter's raised cosine filter
* @param stopBandStart frequency in Hertz where the stopband starts. The passband extends to the signal band
* edge of (1 + alpha) * symbolRate / 2 and the region between the passband edge and the stopband start is a
* transition band that is excluded from the fit.
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length, linear phase, unity gain at DC
*/
public static float[] getBandLimitedIntegrateAndDump(double sampleRate, double symbolRate, double alpha,
double stopBandStart, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
double nyquist = sampleRate / 2.0;
double passBandEdge = (1.0 + alpha) * symbolRate / 2.0;
if(stopBandStart <= passBandEdge || stopBandStart >= nyquist)
{
throw new IllegalArgumentException("Stop band start [" + stopBandStart + "] must be between the pass band " +
"edge [" + passBandEdge + "] and Nyquist [" + nyquist + "]");
}
int length = getOddLength(symbolSpan * sampleRate / symbolRate);
double symbolPeriod = 1.0 / symbolRate;
//Least-squares weight applied in the stopband, relative to a weight of 1.0 in the passband.
double stopBandWeight = 1.0;
double[] taps = getLeastSquaresSymmetricTaps(sampleRate, length, frequency -> {
if(frequency <= passBandEdge)
{
return new double[]{sinc(frequency * symbolPeriod), 1.0};
}
else if(frequency >= stopBandStart)
{
return new double[]{0.0, stopBandWeight};
}
return null; //Transition band
});
return normalizeToUnityDC(taps);
}
/**
* Designs a one symbol period integrator (moving sum) for the FM discriminator output of a differentially encoded
* QPSK (ie P25 LSM / CQPSK) signal. The FM discriminator produces the phase change between consecutive samples,
* so summing one symbol period of the discriminator output produces the (unwrapped) phase change across the symbol
* period, which is the differential demodulation of the symbol: arg(x[n] * conj(x[n - samplesPerSymbol])), modulo
* 2 * PI.
*
* A fractional samples per symbol value is handled by weighting the final tap with the fractional remainder, which
* is equivalent to linearly interpolating the delayed (unwrapped) phase.
*
* @param samplesPerSymbol must be at least 1.0
* @return filter taps scaled to unity gain at DC so the output is in radians per sample, like the C4FM receive
* filter output.
*/
public static float[] getDifferentialIntegrator(double samplesPerSymbol)
{
if(samplesPerSymbol < 1.0)
{
throw new IllegalArgumentException("Samples per symbol [" + samplesPerSymbol + "] must be at least 1.0");
}
int integral = (int)Math.floor(samplesPerSymbol);
double fractional = samplesPerSymbol - integral;
float[] taps = new float[fractional > 0.0 ? integral + 1 : integral];
Arrays.fill(taps, 0, integral, (float)(1.0 / samplesPerSymbol));
if(fractional > 0.0)
{
taps[integral] = (float)(fractional / samplesPerSymbol);
}
return taps;
}
/**
* Root raised cosine filter taps in double precision, normalized to unity gain at DC. See
* getRootRaisedCosineFilter().
*/
private static double[] getRootRaisedCosineTaps(double sampleRate, double symbolRate, double alpha, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(alpha <= 0 || alpha > 1)
{
throw new IllegalArgumentException("Alpha must be in the range (0 - 1]");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
int length = getOddLength(symbolSpan * sampleRate / symbolRate);
int middle = (length - 1) / 2;
double[] taps = new double[length];
double sum = 0.0;
for(int n = 0; n < length; n++)
{
//Time in symbol periods relative to the filter center
double t = (n - middle) * symbolRate / sampleRate;
double value;
if(Math.abs(t) < 1e-9)
{
value = 1.0 - alpha + 4.0 * alpha / Math.PI;
}
else if(Math.abs(Math.abs(t) - 1.0 / (4.0 * alpha)) < 1e-9)
{
//Limit at the singularity t = +/- T / (4 * alpha)
value = (alpha / Math.sqrt(2.0)) *
((1.0 + 2.0 / Math.PI) * Math.sin(Math.PI / (4.0 * alpha)) +
(1.0 - 2.0 / Math.PI) * Math.cos(Math.PI / (4.0 * alpha)));
}
else
{
double numerator = Math.sin(Math.PI * t * (1.0 - alpha)) +
4.0 * alpha * t * Math.cos(Math.PI * t * (1.0 + alpha));
double denominator = Math.PI * t * (1.0 - Math.pow(4.0 * alpha * t, 2.0));
value = numerator / denominator;
}
taps[n] = value;
sum += value;
}
for(int n = 0; n < length; n++)
{
taps[n] /= sum;
}
return taps;
}
/**
* Odd tap count that is at least the specified length, so the filter has an integer group delay of
* (length - 1) / 2 samples.
*/
private static int getOddLength(double length)
{
int oddLength = (int)Math.ceil(length);
if(oddLength % 2 == 0)
{
oddLength++;
}
return oddLength;
}
/**
* Desired response and least-squares weight for each frequency grid point of getLeastSquaresSymmetricTaps().
*/
private interface LeastSquaresObjective
{
/**
* @param frequency in Hertz
* @return {desired response, weight} at the frequency, or null to exclude it from the fit (ie transition band)
*/
double[] evaluate(double frequency);
}
/**
* Designs a symmetric (linear phase) FIR filter whose response is a weighted least-squares fit to the objective.
*
* @param sampleRate in Hertz
* @param length of the filter, odd
* @param objective desired response and weight at each frequency
* @return filter taps, not normalized
*/
private static double[] getLeastSquaresSymmetricTaps(double sampleRate, int length, LeastSquaresObjective objective)
{
double nyquist = sampleRate / 2.0;
int middle = (length - 1) / 2;
int coefficientCount = middle + 1;
//A symmetric filter has the zero-phase response A(f) = c[0] + sum(m=1..middle) c[m] * 2cos(2*pi*f*m/fs)
//where c[m] = h[middle - m] = h[middle + m]. Accumulate the weighted normal equations (B'WB)c = B'Wd over a
//dense frequency grid (16 points per coefficient) from DC to Nyquist.
double[][] normal = new double[coefficientCount][coefficientCount];
double[] rhs = new double[coefficientCount];
double[] basis = new double[coefficientCount];
int gridSize = 16 * coefficientCount;
for(int g = 0; g <= gridSize; g++)
{
double frequency = nyquist * g / gridSize;
double[] desiredAndWeight = objective.evaluate(frequency);
if(desiredAndWeight == null)
{
continue;
}
double desired = desiredAndWeight[0];
double weight = desiredAndWeight[1];
basis[0] = 1.0;
for(int m = 1; m < coefficientCount; m++)
{
basis[m] = 2.0 * Math.cos(2.0 * Math.PI * frequency * m / sampleRate);
}
for(int i = 0; i < coefficientCount; i++)
{
double weighted = weight * basis[i];
rhs[i] += weighted * desired;
for(int j = i; j < coefficientCount; j++)
{
normal[i][j] += weighted * basis[j];
}
}
}
//Fill in the lower triangle of the symmetric normal matrix
for(int i = 0; i < coefficientCount; i++)
{
for(int j = 0; j < i; j++)
{
normal[i][j] = normal[j][i];
}
}
double[] coefficients = solve(normal, rhs);
//Expand to the full symmetric impulse response
double[] taps = new double[length];
for(int m = 0; m < coefficientCount; m++)
{
taps[middle - m] = coefficients[m];
taps[middle + m] = coefficients[m];
}
return taps;
}
/**
* Normalizes the filter taps to unity gain at DC.
* @param taps to normalize
* @return normalized taps
*/
private static float[] normalizeToUnityDC(double[] taps)
{
double sum = 0.0;
for(double tap : taps)
{
sum += tap;
}
float[] output = new float[taps.length];
for(int n = 0; n < taps.length; n++)
{
output[n] = (float)(taps[n] / sum);
}
return output;
}
/**
* Sinc: sin(x) / x where x = pi * f * T, the frequency response of an integrate and dump filter of length T.
*
* @param normalizedFrequency f * T (frequency divided by the symbol rate)
*/
private static double sinc(double normalizedFrequency)
{
if(normalizedFrequency == 0.0)
{
return 1.0;
}
double x = Math.PI * normalizedFrequency;
return Math.sin(x) / x;
}
/**
* Inverse sinc: x / sin(x) where x = pi * f * T.
*
* @param normalizedFrequency f * T (frequency divided by the symbol rate)
*/
private static double inverseSinc(double normalizedFrequency)
{
if(normalizedFrequency == 0.0)
{
return 1.0;
}
double x = Math.PI * normalizedFrequency;
return x / Math.sin(x);
}
/**
* Solves Ax = b using Gaussian elimination with partial pivoting. A and b are modified.
*/
private static double[] solve(double[][] a, double[] b)
{
int n = b.length;
for(int column = 0; column < n; column++)
{
int pivot = column;
for(int row = column + 1; row < n; row++)
{
if(Math.abs(a[row][column]) > Math.abs(a[pivot][column]))
{
pivot = row;
}
}
double[] swapRow = a[column];
a[column] = a[pivot];
a[pivot] = swapRow;
double swapValue = b[column];
b[column] = b[pivot];
b[pivot] = swapValue;
for(int row = column + 1; row < n; row++)
{
double factor = a[row][column] / a[column][column];
b[row] -= factor * b[column];
for(int k = column; k < n; k++)
{
a[row][k] -= factor * a[column][k];
}
}
}
double[] x = new double[n];
for(int row = n - 1; row >= 0; row--)
{
double sum = b[row];
for(int k = row + 1; k < n; k++)
{
sum -= a[row][k] * x[k];
}
x[row] = sum / a[row][row];
}
return x;
}
/**
* Evaluates the filter to determine the magnitude response (dB) at the specified frequency.
*

View File

@@ -82,7 +82,6 @@ public class FilterViewer extends Application
}
// float[] taps = FilterFactory.getHighPass(8000, 3000, filterLength, windowType);
// float[] taps = FilterFactory.getRootRaisedCosine(50000.0 / 4800.0, 20, 0.2f);
// taps = HilbertTransform.HALF_BAND_FILTER_47_TAP;

View File

@@ -0,0 +1,100 @@
/*
* *****************************************************************************
* 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.fm;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
/**
* Calculates the soft symbol reliability (inverse of the relative noise variance) of each sample produced by an FM
* discriminator followed by a post-discriminator FIR filter (ie a C4FM receive filter), from the complex samples that
* are input to the discriminator.
*
* The discriminator output is the phase difference between consecutive samples: d[n] = phase(x[n]) - phase(x[n - 1]),
* so the filtered output y[n] = sum(h[k] * d[n - k]) = sum(g[k] * phase(x[n - k])) where g[k] = h[k] - h[k - 1] (with
* h[-1] = h[L] = 0). The phase noise variance of each sample is inversely proportional to the sample power, so the
* noise variance of the filtered output is proportional to sum(g[k]^2 / |x[n - k]|^2), and the reliability is the
* inverse of that sum. The sum is calculated as an FIR filter of the inverse sample power with taps g[k]^2.
*
* Testing against a P25 LSM simulcast recording found that most of the symbol errors occur in the small fraction of
* symbols with the lowest envelope, so weighting the soft decision decoding by the reliability recovers many of the
* blocks that fail unweighted soft decision decoding.
*/
public class FMDiscriminatorReliability
{
//Minimum sample power, avoiding division by zero for samples with zero power
private static final float MINIMUM_POWER = 1e-30f;
private final IRealFilter mInversePowerFilter;
/**
* Constructs an instance
* @param postDiscriminatorFilterTaps for the FIR filter applied to the FM discriminator output.
*/
public FMDiscriminatorReliability(float[] postDiscriminatorFilterTaps)
{
mInversePowerFilter = new RealFIRFilter(getInversePowerFilterTaps(postDiscriminatorFilterTaps));
}
/**
* Calculates the inverse sample power filter taps: g[k]^2 where g[k] = h[k] - h[k - 1] for k = 0 to L, with
* h[-1] = h[L] = 0.
* @param taps of the post-discriminator filter (h)
* @return inverse power filter taps, one longer than the post-discriminator filter.
*/
public static float[] getInversePowerFilterTaps(float[] taps)
{
float[] squared = new float[taps.length + 1];
for(int k = 0; k <= taps.length; k++)
{
float current = k < taps.length ? taps[k] : 0.0f;
float previous = k > 0 ? taps[k - 1] : 0.0f;
float difference = current - previous;
squared[k] = difference * difference;
}
return squared;
}
/**
* Calculates the reliability of each filtered discriminator output sample from the discriminator input samples.
* @param i inphase samples input to the FM discriminator
* @param q quadrature samples input to the FM discriminator
* @return reliabilities, parallel to the filtered discriminator output samples.
*/
public float[] calculate(float[] i, float[] q)
{
float[] inversePower = new float[i.length];
for(int x = 0; x < i.length; x++)
{
inversePower[x] = 1.0f / Math.max(i[x] * i[x] + q[x] * q[x], MINIMUM_POWER);
}
float[] reliabilities = mInversePowerFilter.filter(inversePower);
for(int x = 0; x < reliabilities.length; x++)
{
reliabilities[x] = reliabilities[x] > 0.0f ? 1.0f / reliabilities[x] : 0.0f;
}
return reliabilities;
}
}

View File

@@ -32,9 +32,21 @@ public class ViterbiDecoder_1_2_P25 extends ViterbiDecoder
/**
* Maximum per-symbol error magnitude (radians) applied to the soft decision branch metric. FM discriminator noise
* is impulsive (clicks), and an unbounded squared error lets a single corrupted symbol dominate the path metric.
* Noise injection testing against a P25 control channel recording found a peak at 1.75 - 2.0 radians.
* Noise injection testing against a P25 control channel recording found a peak at 1.75 - 2.0 radians. Noise
* injection testing against P25 LSM recordings (phase wrapped soft symbols) also found 1.75 performed best.
*/
private static final double SOFT_SYMBOL_ERROR_CLIP = 1.75;
/**
* Per-symbol error magnitude clip applied when the branch metric of phase wrapped soft symbols (ie P25 LSM) is
* weighted by symbol reliabilities. The reliability weighting de-emphasizes the corrupted (low envelope) symbols
* that the clip otherwise protects against, so the weighted metric doesn't clip (PI is the maximum phase wrapped
* error). Noise injection testing against P25 LSM recordings found that no clip produced 1.6-7.0% more valid
* messages than the 1.75 clip. Soft symbols that aren't phase wrapped (ie C4FM) can have errors beyond PI from FM
* discriminator clicks, so the weighted metric for those uses the SOFT_SYMBOL_ERROR_CLIP, which testing against a
* P25 C4FM recording found 4.5% better than no clip.
*/
private static final double WEIGHTED_PHASE_WRAPPED_SOFT_SYMBOL_ERROR_CLIP = Math.PI;
private static final double TWO_PI = 2.0 * Math.PI;
public ViterbiDecoder_1_2_P25()
{
super(2,4);
@@ -68,12 +80,48 @@ public class ViterbiDecoder_1_2_P25 extends ViterbiDecoder
* @return decoded 96-bit message
*/
public CorrectedBinaryMessage decodeSoft(float[] softSymbols)
{
return decodeSoft(softSymbols, false);
}
/**
* Soft decision Viterbi decoding of a deinterleaved 1/2 rate TCM encoded P25 block. See decodeSoft(float[]).
*
* @param softSymbols 98 deinterleaved soft symbols (radians, ideal values +/- PI/4 and +/- 3*PI/4).
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie differentially demodulated P25 LSM)
* where the soft symbol error is measured around the circle so that a soft symbol near +/- PI is near both the
* +3 and -3 symbols. False for C4FM where soft symbols are not wrapped and a soft symbol beyond +/- PI favors the
* outer symbol of the same polarity.
* @return decoded 96-bit message
*/
public CorrectedBinaryMessage decodeSoft(float[] softSymbols, boolean phaseWrapped)
{
return decodeSoft(softSymbols, null, phaseWrapped);
}
/**
* Soft decision Viterbi decoding of a deinterleaved 1/2 rate TCM encoded P25 block with each symbol's branch metric
* weighted by the symbol's reliability (ie the inverse of the soft symbol noise variance). See decodeSoft(float[]).
*
* The weights only need to be proportional to the symbol reliabilities, since they're normalized to unity mean
* across the block.
*
* @param softSymbols 98 deinterleaved soft symbols (radians, ideal values +/- PI/4 and +/- 3*PI/4).
* @param reliabilities for each deinterleaved soft symbol, or null to decode without weighting.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie differentially demodulated P25 LSM)
* @return decoded 96-bit message
*/
public CorrectedBinaryMessage decodeSoft(float[] softSymbols, float[] reliabilities, boolean phaseWrapped)
{
if(softSymbols == null || softSymbols.length % 2 != 0 || softSymbols.length < 4)
{
throw new IllegalArgumentException("Soft symbols must contain an integral number of symbol pairs");
}
double[] weights = getWeights(reliabilities, softSymbols.length);
double clip = weights != null && phaseWrapped ? WEIGHTED_PHASE_WRAPPED_SOFT_SYMBOL_ERROR_CLIP :
SOFT_SYMBOL_ERROR_CLIP;
int steps = softSymbols.length / 2;
int states = getInputValueCount();
double[] metrics = new double[states];
@@ -92,9 +140,18 @@ public class ViterbiDecoder_1_2_P25 extends ViterbiDecoder
for(int output = 0; output < branchMetrics.length; output++)
{
double errorFirst = Math.min(Math.abs(first - getIdealPhase(output >> 2)), SOFT_SYMBOL_ERROR_CLIP);
double errorSecond = Math.min(Math.abs(second - getIdealPhase(output & 3)), SOFT_SYMBOL_ERROR_CLIP);
branchMetrics[output] = errorFirst * errorFirst + errorSecond * errorSecond;
double errorFirst = Math.min(getError(first, getIdealPhase(output >> 2), phaseWrapped), clip);
double errorSecond = Math.min(getError(second, getIdealPhase(output & 3), phaseWrapped), clip);
if(weights != null)
{
branchMetrics[output] = weights[2 * step] * errorFirst * errorFirst +
weights[2 * step + 1] * errorSecond * errorSecond;
}
else
{
branchMetrics[output] = errorFirst * errorFirst + errorSecond * errorSecond;
}
}
java.util.Arrays.fill(nextMetrics, Double.MAX_VALUE);
@@ -170,6 +227,66 @@ public class ViterbiDecoder_1_2_P25 extends ViterbiDecoder
return message;
}
/**
* Absolute error between the soft symbol and the ideal symbol phase.
* @param softSymbol received (radians)
* @param idealPhase of the candidate symbol (radians)
* @param phaseWrapped to measure the error around the circle (+/- PI)
* @return absolute error in radians
*/
private static double getError(float softSymbol, float idealPhase, boolean phaseWrapped)
{
double error = softSymbol - idealPhase;
if(phaseWrapped)
{
error -= TWO_PI * Math.round(error / TWO_PI);
}
return Math.abs(error);
}
/**
* Normalizes the symbol reliabilities to unity mean for use as branch metric weights.
* @param reliabilities for each soft symbol, or null.
* @param length of the soft symbol array.
* @return weights or null when the reliabilities are unavailable or unusable (ie all zero).
*/
private static double[] getWeights(float[] reliabilities, int length)
{
if(reliabilities == null || reliabilities.length != length)
{
return null;
}
double sum = 0.0;
for(float reliability : reliabilities)
{
if(!(reliability >= 0.0f) || Float.isInfinite(reliability)) //Rejects NaN
{
return null;
}
sum += reliability;
}
if(sum <= 0.0)
{
return null;
}
double scale = length / sum;
double[] weights = new double[length];
for(int x = 0; x < length; x++)
{
weights[x] = reliabilities[x] * scale;
}
return weights;
}
/**
* Ideal soft symbol phase for a 2-bit dibit value where the MSB is the first transmitted bit.
*/

View File

@@ -23,7 +23,6 @@ import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.fm.FmDemodulatorFactory;
@@ -40,7 +39,6 @@ import io.github.dsheirer.module.decode.dmr.message.data.lc.LCMessage;
import io.github.dsheirer.module.decode.dmr.message.data.lc.full.FullLCMessage;
import io.github.dsheirer.module.decode.dmr.message.data.lc.shorty.ShortLCMessage;
import io.github.dsheirer.module.decode.dmr.message.data.terminator.Terminator;
import io.github.dsheirer.module.decode.nxdn.NXDNFilterDesigner;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.buffer.IByteBufferProvider;
@@ -54,8 +52,6 @@ import java.io.IOException;
import java.nio.ByteBuffer;
import java.text.DecimalFormat;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -88,7 +84,8 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
//DMR transmit filter is a root raised cosine with 0.2 roll-off, so the receive filter is a matched RRC filter.
private static final double RECEIVE_FILTER_ALPHA = 0.2;
private static final int RECEIVE_FILTER_SYMBOL_SPAN = 12;
private static final Map<Double,float[]> BASEBAND_FILTERS = new ConcurrentHashMap<>();
private static final double BASEBAND_FILTER_PASS_BAND = 5100.0; //Hertz
private static final double BASEBAND_FILTER_STOP_BAND = 6500.0; //Hertz
private IDemodulator mDemodulator;
private final DMRMessageFramer mMessageFramer;
private final DMRSymbolProcessor mSymbolProcessor;
@@ -155,17 +152,13 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
setDecimatedSampleRate(decimatedSampleRate);
//Receive filter: root raised cosine matched to the DMR transmit filter, normalized to unity gain at DC.
double[] receiveFilterTaps = NXDNFilterDesigner.designRootRaisedCosine(decimatedSampleRate, SYMBOL_RATE,
RECEIVE_FILTER_ALPHA, RECEIVE_FILTER_SYMBOL_SPAN);
float[] taps = new float[receiveFilterTaps.length];
for(int x = 0; x < receiveFilterTaps.length; x++)
{
taps[x] = (float)receiveFilterTaps[x];
}
mReceiveFilter = new RealFIRFilter(taps);
mReceiveFilter = new RealFIRFilter(FilterFactory.getRootRaisedCosineFilter(decimatedSampleRate, SYMBOL_RATE,
RECEIVE_FILTER_ALPHA, RECEIVE_FILTER_SYMBOL_SPAN));
mBasebandFilterI = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
mBasebandFilterQ = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
float[] basebandTaps = FilterFactory.getRemezLowPass(decimatedSampleRate, BASEBAND_FILTER_PASS_BAND,
BASEBAND_FILTER_STOP_BAND);
mBasebandFilterI = FilterFactory.getRealFilter(basebandTaps);
mBasebandFilterQ = FilterFactory.getRealFilter(basebandTaps);
mDemodulator = FmDemodulatorFactory.getFmDemodulator();
mSymbolProcessor.setSamplesPerSymbol(samplesPerSymbol);
@@ -197,51 +190,6 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
mSymbolProcessor.process(demodulated);
}
/**
* Constructs a baseband filter for this decoder using the current sample rate
*/
private float[] getBasebandFilter(double sampleRate)
{
//Filter cache is shared across decoder instances that may be started from different threads
float[] cached = BASEBAND_FILTERS.get(sampleRate);
if(cached != null)
{
return cached;
}
FIRFilterSpecification specification = FIRFilterSpecification
.lowPassBuilder()
.sampleRate(sampleRate)
.passBandCutoff(5100)
.passBandAmplitude(1.0).passBandRipple(0.01) //.01
.stopBandAmplitude(0.0).stopBandStart(6500) //6500
.stopBandRipple(0.01).build();
float[] coefficients = null;
try
{
coefficients = FilterFactory.getTaps(specification);
if(coefficients != null)
{
BASEBAND_FILTERS.put(sampleRate, coefficients);
}
}
catch(Exception e) //FilterDesignException or runtime failures from the Remez designer
{
LOGGER.error("Error designing DMR baseband filter for sample rate [" + sampleRate + "]", e);
}
if(coefficients == null)
{
throw new IllegalStateException("Unable to design low pass filter for sample rate [" + sampleRate + "]");
}
return coefficients;
}
/**
* Implements the IByteBufferProvider interface - delegates to the symbol processor
*/
@@ -306,9 +254,14 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
switch(sourceEvent.getEvent())
{
case NOTIFICATION_FREQUENCY_CHANGE:
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
mSymbolProcessor.resetBalance();
break;
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
//Tuner PPM changes are small (auto-PPM steps are <= 10 Hz) and the symbol processor balance tracks
//the change. Resetting drops the symbol processor synchronization, and the symbol timing tracking
//with it, until the next sync detection, which costs messages on channels with long sync intervals
//(ie P25 voice).
break;
case NOTIFICATION_SAMPLE_RATE_CHANGE:
setSampleRate(sourceEvent.getValue().doubleValue());
break;

View File

@@ -23,7 +23,6 @@ import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.fm.FmDemodulatorFactory;
@@ -45,8 +44,6 @@ import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -57,7 +54,8 @@ public class NXDNDecoder extends FeedbackDecoder implements IByteBufferProvider,
ISourceEventListener, Listener<ComplexSamples>
{
private static final Logger LOGGER = LoggerFactory.getLogger(NXDNDecoder.class);
private static final Map<String,float[]> BASEBAND_FILTERS = new ConcurrentHashMap<>();
//NXDN transmit filter root raised cosine roll-off
private static final double RECEIVE_FILTER_ALPHA = 0.2;
private final DecodeConfigNXDN mConfig;
private final NXDNSymbolProcessor mSymbolProcessor;
private final NXDNMessageFramer mMessageFramer;
@@ -141,13 +139,13 @@ public class NXDNDecoder extends FeedbackDecoder implements IByteBufferProvider,
int rrcSymbolSpan = 12;
int inverseSyncSymbolSpan = 4;
//Create an RRC pulse shaping filter convolved with an inverse sync (ie restoration) filter.
float[] taps = NXDNFilterDesigner.designNXDN(decimatedSampleRate, mConfig.getTransmissionMode().getSymbolRate(),
rrcSymbolSpan, inverseSyncSymbolSpan);
// int symbolLength = 26;
// float rrcAlpha = 0.2f;
// taps = FilterFactory.getRRC(samplesPerSymbol, symbolLength, rrcAlpha);
float[] taps = FilterFactory.getRootRaisedCosineInverseSincFilter(decimatedSampleRate,
mConfig.getTransmissionMode().getSymbolRate(), RECEIVE_FILTER_ALPHA, rrcSymbolSpan,
inverseSyncSymbolSpan);
mPulseShapingFilter = new RealFIRFilter(taps);
float[] basebandTaps = getBasebandFilter(decimatedSampleRate, mConfig);
ChannelSpecification channel = mConfig.getChannelSpecification();
float[] basebandTaps = FilterFactory.getRemezLowPass(decimatedSampleRate, channel.getPassFrequency(),
channel.getStopFrequency());
mBasebandFilterI = FilterFactory.getRealFilter(basebandTaps);
mBasebandFilterQ = FilterFactory.getRealFilter(basebandTaps);
mDemodulator = FmDemodulatorFactory.getFmDemodulator();
@@ -178,52 +176,6 @@ public class NXDNDecoder extends FeedbackDecoder implements IByteBufferProvider,
mSymbolProcessor.process(demodulated);
}
/**
* Constructs a baseband filter for this decoder using the current sample rate
*/
private float[] getBasebandFilter(double sampleRate, DecodeConfigNXDN config)
{
String mapKey = config.getTransmissionMode().name() + sampleRate;
//Filter cache is shared across decoder instances that may be started from different threads
float[] coefficients = BASEBAND_FILTERS.get(mapKey);
if(coefficients == null)
{
ChannelSpecification channel = config.getChannelSpecification();
FIRFilterSpecification specification = FIRFilterSpecification
.lowPassBuilder()
.sampleRate(sampleRate)
.passBandCutoff(channel.getPassFrequency())
.passBandAmplitude(1.0).passBandRipple(0.01)
.stopBandAmplitude(0.0).stopBandStart(channel.getStopFrequency())
.stopBandRipple(0.01).build();
try
{
coefficients = FilterFactory.getTaps(specification);
if(coefficients != null)
{
BASEBAND_FILTERS.put(mapKey, coefficients);
}
}
catch(Exception e) //FilterDesignException or runtime failures from the Remez designer
{
LOGGER.error("Error designing NXDN " + config.getTransmissionMode() + " baseband filter for sample rate [" +
sampleRate + "]", e);
}
}
if(coefficients == null)
{
throw new IllegalStateException("Unable to design low pass filter for sample rate [" + sampleRate + "]");
}
return coefficients;
}
/**
* Implements the IByteBufferProvider interface - delegates to the symbol processor
*/
@@ -272,9 +224,14 @@ public class NXDNDecoder extends FeedbackDecoder implements IByteBufferProvider,
switch(sourceEvent.getEvent())
{
case NOTIFICATION_FREQUENCY_CHANGE:
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
mSymbolProcessor.resetBalance();
break;
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
//Tuner PPM changes are small (auto-PPM steps are <= 10 Hz) and the symbol processor balance tracks the
//change. Resetting drops the symbol processor synchronization, and the symbol timing tracking with it,
//until the next sync detection, which costs messages on channels with long sync intervals (ie P25
//voice).
break;
case NOTIFICATION_SAMPLE_RATE_CHANGE:
setSampleRate(sourceEvent.getValue().doubleValue());
break;

View File

@@ -1,358 +0,0 @@
package io.github.dsheirer.module.decode.nxdn;
/**
* Designs an inverse sinc (x / sin(x)) FIR filter for NXDN 4FSK receivers.
*
* The NXDN transmitter applies a sinc response, sin(pi*f*T)/(pi*f*T), to the symbol stream along with the root
* raised cosine filter. The receiver cascades this inverse sinc filter, H(f) = (pi*f*T)/sin(pi*f*T), with its own
* root raised cosine filter to restore an overall raised cosine (Nyquist) response.
*
* The inverse sinc grows without bound as f approaches the symbol rate, so the response is only matched over the
* occupied bandwidth of the signal, |f| <= (1 + alpha) * symbolRate / 2. Beyond that edge the cascaded RRC filter
* suppresses the signal, so that region is lightly weighted toward a flat gain equal to the band-edge value, which
* keeps the filter from boosting out-of-band noise.
*
* The taps are a weighted least-squares fit of a linear phase (symmetric, odd length) FIR filter to that target
* response on a dense frequency grid.
*/
public class NXDNFilterDesigner
{
/** NXDN root raised cosine roll-off factor. */
public static final double NXDN_ALPHA = 0.2;
/** Default filter length in symbol periods. */
private static final int DEFAULT_SYMBOL_SPAN = 4;
/** Least-squares weight applied beyond the band edge, relative to a weight of 1.0 in band. */
private static final double STOPBAND_WEIGHT = 0.01;
/** Frequency grid points per filter coefficient used for the least-squares fit. */
private static final int GRID_DENSITY = 16;
/**
* Designs an inverse sinc filter using the NXDN roll-off factor (0.2) to define the matched bandwidth.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second (2400 for NXDN48, 4800 for NXDN96)
* @return filter taps, odd length, linear phase, unity gain at DC
*/
public static float[] design(double sampleRate, double symbolRate)
{
return design(sampleRate, symbolRate, NXDN_ALPHA, DEFAULT_SYMBOL_SPAN);
}
/**
* Designs an inverse sinc filter.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor of the companion RRC filter, defining the band edge (1 + alpha) * symbolRate / 2
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length, linear phase, unity gain at DC
*/
public static float[] design(double sampleRate, double symbolRate, double alpha, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(alpha < 0 || alpha > 1)
{
throw new IllegalArgumentException("Alpha must be in the range 0 - 1");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
double nyquist = sampleRate / 2.0;
double bandEdge = (1.0 + alpha) * symbolRate / 2.0;
if(bandEdge >= nyquist)
{
throw new IllegalArgumentException("Sample rate [" + sampleRate +
"] is too low for symbol rate [" + symbolRate + "] - band edge exceeds Nyquist");
}
//Odd tap count so the filter has an integer group delay of (length - 1) / 2 samples
int length = (int)Math.ceil(symbolSpan * sampleRate / symbolRate);
if(length % 2 == 0)
{
length++;
}
length = Math.max(length, 9);
int middle = (length - 1) / 2;
int coefficientCount = middle + 1;
double symbolPeriod = 1.0 / symbolRate;
double edgeGain = inverseSinc(bandEdge * symbolPeriod);
//A symmetric filter has the zero-phase response A(f) = c[0] + sum(m=1..middle) c[m] * 2cos(2*pi*f*m/fs)
//where c[m] = h[middle - m] = h[middle + m]. Accumulate the weighted normal equations (B'WB)c = B'Wd over a
//dense frequency grid from DC to Nyquist.
double[][] normal = new double[coefficientCount][coefficientCount];
double[] rhs = new double[coefficientCount];
double[] basis = new double[coefficientCount];
int gridSize = GRID_DENSITY * coefficientCount;
for(int g = 0; g <= gridSize; g++)
{
double frequency = nyquist * g / gridSize;
boolean inBand = frequency <= bandEdge;
double desired = inBand ? inverseSinc(frequency * symbolPeriod) : edgeGain;
double weight = inBand ? 1.0 : STOPBAND_WEIGHT;
basis[0] = 1.0;
for(int m = 1; m < coefficientCount; m++)
{
basis[m] = 2.0 * Math.cos(2.0 * Math.PI * frequency * m / sampleRate);
}
for(int i = 0; i < coefficientCount; i++)
{
double weighted = weight * basis[i];
rhs[i] += weighted * desired;
for(int j = i; j < coefficientCount; j++)
{
normal[i][j] += weighted * basis[j];
}
}
}
//Fill in the lower triangle of the symmetric normal matrix
for(int i = 0; i < coefficientCount; i++)
{
for(int j = 0; j < i; j++)
{
normal[i][j] = normal[j][i];
}
}
double[] coefficients = solve(normal, rhs);
//Expand to the full symmetric impulse response and normalize to unity gain at DC
double[] taps = new double[length];
double sum = 0.0;
for(int m = 0; m < coefficientCount; m++)
{
taps[middle - m] = coefficients[m];
taps[middle + m] = coefficients[m];
}
for(double tap : taps)
{
sum += tap;
}
float[] output = new float[length];
for(int n = 0; n < length; n++)
{
output[n] = (float)(taps[n] / sum);
}
return output;
}
/** Default root raised cosine filter length in symbol periods for the combined NXDN receive filter. */
private static final int DEFAULT_RRC_SYMBOL_SPAN = 8;
/**
* Designs the complete NXDN receive filter: a root raised cosine filter (alpha 0.2) convolved with the inverse
* sinc filter. Cascaded with the NXDN transmit filter (RRC and sinc), the overall response is a raised cosine
* Nyquist filter with no intersymbol interference at the symbol sampling instants.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second (2400 for NXDN48, 4800 for NXDN96)
* @return filter taps, odd length, linear phase, unity gain at DC
*/
public static float[] designNXDN(double sampleRate, double symbolRate)
{
return designNXDN(sampleRate, symbolRate, DEFAULT_RRC_SYMBOL_SPAN, DEFAULT_SYMBOL_SPAN);
}
/**
* Designs the complete NXDN receive filter: a root raised cosine filter (alpha 0.2) convolved with the inverse
* sinc filter.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param rrcSymbolSpan root raised cosine filter length in symbol periods
* @param inverseSincSymbolSpan inverse sinc filter length in symbol periods
* @return filter taps, odd length (sum of both filter lengths - 1), linear phase, unity gain at DC
*/
public static float[] designNXDN(double sampleRate, double symbolRate, int rrcSymbolSpan, int inverseSincSymbolSpan)
{
double[] rrc = designRootRaisedCosine(sampleRate, symbolRate, NXDN_ALPHA, rrcSymbolSpan);
float[] inverseSinc = design(sampleRate, symbolRate, NXDN_ALPHA, inverseSincSymbolSpan);
//Full linear convolution - both filters are odd length and symmetric, so the result is too
double[] combined = new double[rrc.length + inverseSinc.length - 1];
for(int i = 0; i < rrc.length; i++)
{
for(int j = 0; j < inverseSinc.length; j++)
{
combined[i + j] += rrc[i] * inverseSinc[j];
}
}
//Normalize to unity gain at DC so the discriminator output keeps its deviation scaling
double sum = 0.0;
for(double tap : combined)
{
sum += tap;
}
float[] output = new float[combined.length];
for(int n = 0; n < combined.length; n++)
{
output[n] = (float)(combined[n] / sum);
}
return output;
}
/**
* Designs a root raised cosine filter from the closed form impulse response, normalized to unity gain at DC.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param alpha roll-off factor (0 - 1)
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length, linear phase
*/
public static double[] designRootRaisedCosine(double sampleRate, double symbolRate, double alpha, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(alpha <= 0 || alpha > 1)
{
throw new IllegalArgumentException("Alpha must be in the range (0 - 1]");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
int length = (int)Math.ceil(symbolSpan * sampleRate / symbolRate);
if(length % 2 == 0)
{
length++;
}
int middle = (length - 1) / 2;
double[] taps = new double[length];
double sum = 0.0;
for(int n = 0; n < length; n++)
{
//Time in symbol periods relative to the filter center
double t = (n - middle) * symbolRate / sampleRate;
double value;
if(Math.abs(t) < 1e-9)
{
value = 1.0 - alpha + 4.0 * alpha / Math.PI;
}
else if(Math.abs(Math.abs(t) - 1.0 / (4.0 * alpha)) < 1e-9)
{
//Limit at the singularity t = +/- T / (4 * alpha)
value = (alpha / Math.sqrt(2.0)) *
((1.0 + 2.0 / Math.PI) * Math.sin(Math.PI / (4.0 * alpha)) +
(1.0 - 2.0 / Math.PI) * Math.cos(Math.PI / (4.0 * alpha)));
}
else
{
double numerator = Math.sin(Math.PI * t * (1.0 - alpha)) +
4.0 * alpha * t * Math.cos(Math.PI * t * (1.0 + alpha));
double denominator = Math.PI * t * (1.0 - Math.pow(4.0 * alpha * t, 2.0));
value = numerator / denominator;
}
taps[n] = value;
sum += value;
}
for(int n = 0; n < length; n++)
{
taps[n] /= sum;
}
return taps;
}
/**
* Inverse sinc: x / sin(x) where x = pi * f * T.
*
* @param normalizedFrequency f * T (frequency divided by the symbol rate)
*/
private static double inverseSinc(double normalizedFrequency)
{
if(normalizedFrequency == 0.0)
{
return 1.0;
}
double x = Math.PI * normalizedFrequency;
return x / Math.sin(x);
}
/**
* Solves Ax = b using Gaussian elimination with partial pivoting. A and b are modified.
*/
private static double[] solve(double[][] a, double[] b)
{
int n = b.length;
for(int column = 0; column < n; column++)
{
int pivot = column;
for(int row = column + 1; row < n; row++)
{
if(Math.abs(a[row][column]) > Math.abs(a[pivot][column]))
{
pivot = row;
}
}
double[] swapRow = a[column];
a[column] = a[pivot];
a[pivot] = swapRow;
double swapValue = b[column];
b[column] = b[pivot];
b[pivot] = swapValue;
for(int row = column + 1; row < n; row++)
{
double factor = a[row][column] / a[column][column];
b[row] -= factor * b[column];
for(int k = column; k < n; k++)
{
a[row][k] -= factor * a[column][k];
}
}
}
double[] x = new double[n];
for(int row = n - 1; row >= 0; row--)
{
double sum = b[row];
for(int k = row + 1; k < n; k++)
{
sum -= a[row][k] * x[k];
}
x[row] = sum / a[row][row];
}
return x;
}
}

View File

@@ -21,7 +21,6 @@ package io.github.dsheirer.module.decode.p25.phase1;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -32,10 +31,14 @@ import java.util.Map;
public class NACTracker
{
private static final Comparator<Tracker> COUNT_DESCENDING_COMPARATOR = new CountDescendingSorter();
private static final Comparator<Tracker> TIME_ASCENDING_COMPARATOR = new TimeAscendingSorter();
private static final Comparator<Tracker> OBSERVATION_ASCENDING_COMPARATOR = new ObservationAscendingSorter();
private final Map<Integer, Tracker> mTrackerMap = new HashMap<>();
private static final int MAX_TRACKER_COUNT = 3;
private static final int MIN_OBSERVATION_THRESHOLD = 3;
//Observation sequence number, used instead of the system clock to order the trackers by their last observation.
//Recordings decode faster than real time, so many observations share the same millisecond, and the clock would
//make the eviction choice (and the decoded output) depend on the processing speed.
private long mObservationCount = 0;
/**
* Removes all tracked NAC values. Invoke this method after an extended loss of sync.
@@ -47,13 +50,14 @@ public class NACTracker
/**
* Tracks the NAC value. Each time the NAC value is observed, the observation count is incremented so that the
* dominant NAC value will have the highest observation count. Additionally, the last updated timestamp for the
* tracker is updated to now. When the tracker count exceeds the max tracker threshold, the tracker with the
* oldest updated timestamp is thrown out.
* dominant NAC value will have the highest observation count. Additionally, the tracker's last observation is
* updated to this observation. When the tracker count exceeds the max tracker threshold, the tracker with the
* oldest last observation is thrown out.
* @param nac to track
*/
public void track(int nac)
{
mObservationCount++;
Tracker tracker = mTrackerMap.get(nac);
if(tracker == null)
@@ -63,7 +67,7 @@ public class NACTracker
if(mTrackerMap.size() > MAX_TRACKER_COUNT)
{
List<Tracker> trackerList = new ArrayList<>(mTrackerMap.values());
trackerList.sort(TIME_ASCENDING_COMPARATOR);
trackerList.sort(OBSERVATION_ASCENDING_COMPARATOR);
Tracker oldest = trackerList.getFirst();
mTrackerMap.remove(oldest.nac());
}
@@ -104,11 +108,12 @@ public class NACTracker
private class Tracker
{
private int mNAC;
private long mTimestamp = System.currentTimeMillis();
private long mLastObservation = mObservationCount;
private int mCount = 1;
/**
* Constructs an instance to track the NAC. Note: count is set to 1 and timestamp is set to now.
* Constructs an instance to track the NAC. Note: count is set to 1 and the last observation is set to the
* current observation.
* @param nac to track.
*/
private Tracker(int nac)
@@ -126,7 +131,7 @@ public class NACTracker
}
/**
* Increments the observation count for this tracker and updates the timestamp.
* Increments the observation count for this tracker and updates the last observation.
*/
public void increment()
{
@@ -138,16 +143,16 @@ public class NACTracker
mCount = Integer.MAX_VALUE;
}
mTimestamp = System.currentTimeMillis();
mLastObservation = mObservationCount;
}
/**
* Last observation timestamp.
* @return timestamp.
* Observation sequence number of the last observation of this tracker's NAC.
* @return last observation.
*/
public long timestamp()
public long lastObservation()
{
return mTimestamp;
return mLastObservation;
}
/**
@@ -165,7 +170,7 @@ public class NACTracker
@Override
public String toString()
{
return "NAC [" + mNAC + "] COUNT [" + mCount + "] " + new Date(mTimestamp);
return "NAC [" + mNAC + "] COUNT [" + mCount + "] LAST OBSERVATION [" + mLastObservation + "]";
}
}
@@ -183,14 +188,14 @@ public class NACTracker
}
/**
* Sorts the tracked NACs in time order where index 0 is the oldest tracked value.
* Sorts the tracked NACs in last observation order where index 0 is the least recently observed value.
*/
public static class TimeAscendingSorter implements Comparator<Tracker>
public static class ObservationAscendingSorter implements Comparator<Tracker>
{
@Override
public int compare(Tracker o1, Tracker o2)
{
return Long.compare(o1.timestamp(), o2.timestamp());
return Long.compare(o1.lastObservation(), o2.lastObservation());
}
}
}

View File

@@ -23,9 +23,9 @@ import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.fm.FMDiscriminatorReliability;
import io.github.dsheirer.dsp.fm.FmDemodulatorFactory;
import io.github.dsheirer.dsp.fm.IDemodulator;
import io.github.dsheirer.message.IMessage;
@@ -48,8 +48,6 @@ import java.io.IOException;
import java.nio.ByteBuffer;
import java.text.DecimalFormat;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -62,6 +60,9 @@ import org.slf4j.LoggerFactory;
*
* As a child of the FeedbackDecoder, this decoder provides periodic PLL measurements to the tuner for automatic PPM
* correction. It also provides a stream of demodulated soft symbols (in radians) for display to the user.
*
* Soft decision decoding is weighted by each soft symbol's reliability (see FMDiscriminatorReliability), calculated
* from the power of the complex samples spanned by the receive filter.
*/
public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProvider, IComplexSamplesListener,
ISourceEventListener, Listener<ComplexSamples>
@@ -69,11 +70,14 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
private static final Logger LOGGER = LoggerFactory.getLogger(P25P1DecoderC4FM.class);
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.##");
private static final int SYMBOL_RATE = 4800;
//TIA-102.BAAA C4FM transmit raised cosine filter roll-off
private static final double RECEIVE_FILTER_ALPHA = 0.2;
//Band limited integrate and dump receive filter settings. Noise injection testing against P25 C4FM recordings:
//stopband starts of 3300 - 4000 Hz perform similarly, a 5-symbol span is too short, and 12 symbols adds nothing.
private static final double RECEIVE_FILTER_STOP_BAND_START = 3600.0; //Hertz
private static final int RECEIVE_FILTER_SYMBOL_SPAN = 8;
private static final Map<Double,float[]> BASEBAND_FILTERS = new ConcurrentHashMap<>();
private static final double BASEBAND_FILTER_PASS_BAND = 5200.0; //Hertz
private static final double BASEBAND_FILTER_STOP_BAND = 6500.0; //Hertz
private final P25P1SymbolProcessorC4FM mSymbolProcessor;
private final P25P1MessageFramer mMessageFramer = new P25P1MessageFramer();
@@ -84,6 +88,7 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
private IRealFilter mBasebandFilterI;
private IRealFilter mBasebandFilterQ;
private IRealFilter mReceiveFilter;
private FMDiscriminatorReliability mReliability;
@Override
public DecoderType getDecoderType()
@@ -135,12 +140,15 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
//Receive filter: integrate and dump (sinc) response across the signal band with a stopband above the signal band
//to reject FM discriminator noise that an integrate and dump filter's sidelobes would otherwise pass.
float[] receiveFilterTaps = P25P1FilterDesigner.designReceiveFilter(decimatedSampleRate, SYMBOL_RATE,
RECEIVE_FILTER_STOP_BAND_START, RECEIVE_FILTER_SYMBOL_SPAN);
float[] receiveFilterTaps = FilterFactory.getBandLimitedIntegrateAndDump(decimatedSampleRate, SYMBOL_RATE,
RECEIVE_FILTER_ALPHA, RECEIVE_FILTER_STOP_BAND_START, RECEIVE_FILTER_SYMBOL_SPAN);
mReceiveFilter = new RealFIRFilter(receiveFilterTaps);
mReliability = new FMDiscriminatorReliability(receiveFilterTaps);
mBasebandFilterI = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
mBasebandFilterQ = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
float[] basebandTaps = FilterFactory.getRemezLowPass(decimatedSampleRate, BASEBAND_FILTER_PASS_BAND,
BASEBAND_FILTER_STOP_BAND);
mBasebandFilterI = FilterFactory.getRealFilter(basebandTaps);
mBasebandFilterQ = FilterFactory.getRealFilter(basebandTaps);
mDemodulator = FmDemodulatorFactory.getFmDemodulator();
mSymbolProcessor.setSamplesPerSymbol(samplesPerSymbol);
@@ -151,6 +159,24 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
mMessageProcessor.setMessageListener(getMessageListener());
}
/**
* Converts the soft symbol reliabilities to soft decision decoding weights. Noise injection testing against a P25
* C4FM control channel recording found that the square root of the reliability produced 1.6% more valid messages
* at 9 dB carrier to noise (in-band) than the reliability, and 6.3% more than unweighted decoding. The P25 LSM
* decoder uses the reliability, which tested 5% better than the square root against LSM recordings.
* @param reliabilities to convert (modified in place)
* @return weights
*/
private static float[] getWeights(float[] reliabilities)
{
for(int x = 0; x < reliabilities.length; x++)
{
reliabilities[x] = (float)Math.sqrt(reliabilities[x]);
}
return reliabilities;
}
/**
* Primary method for processing incoming complex sample buffers
* @param samples containing channelized complex samples
@@ -170,53 +196,9 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
float[] demodulated = mDemodulator.demodulate(i, q);
demodulated = mReceiveFilter.filter(demodulated);
//Process demodulated samples into symbols and apply message sync detection and framing.
mSymbolProcessor.process(demodulated);
}
/**
* Constructs a baseband filter for this decoder using the current sample rate
*/
private float[] getBasebandFilter(double sampleRate)
{
//Filter cache is shared across decoder instances that may be started from different threads
float[] cached = BASEBAND_FILTERS.get(sampleRate);
if(cached != null)
{
return cached;
}
FIRFilterSpecification specification = FIRFilterSpecification
.lowPassBuilder()
.sampleRate(sampleRate)
.passBandCutoff(5200)
.passBandAmplitude(1.0).passBandRipple(0.01) //.01
.stopBandAmplitude(0.0).stopBandStart(6500) //6500
.stopBandRipple(0.01).build();
float[] coefficients = null;
try
{
coefficients = FilterFactory.getTaps(specification);
if(coefficients != null)
{
BASEBAND_FILTERS.put(sampleRate, coefficients);
}
}
catch(Exception e) //FilterDesignException or runtime failures from the Remez designer
{
LOGGER.error("Error designing P25 Phase 1 C4FM baseband filter for sample rate [" + sampleRate + "]", e);
}
if(coefficients == null)
{
throw new IllegalStateException("Unable to design low pass filter for sample rate [" + sampleRate + "]");
}
return coefficients;
//Process demodulated samples into symbols and apply message sync detection and framing. The soft symbol
//reliabilities weight the soft decision decoding.
mSymbolProcessor.process(demodulated, getWeights(mReliability.calculate(i, q)));
}
/**
@@ -275,9 +257,14 @@ public class P25P1DecoderC4FM extends FeedbackDecoder implements IByteBufferProv
switch(sourceEvent.getEvent())
{
case NOTIFICATION_FREQUENCY_CHANGE:
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
mSymbolProcessor.resetBalance();
break;
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
//Tuner PPM changes are small (auto-PPM steps are <= 10 Hz) and the symbol processor balance tracks the
//change. Resetting drops the symbol processor synchronization, and the symbol timing tracking with it,
//until the next sync detection, which costs messages on channels with long sync intervals (ie P25
//voice).
break;
case NOTIFICATION_SAMPLE_RATE_CHANGE:
setSampleRate(sourceEvent.getValue().doubleValue());
break;

View File

@@ -23,9 +23,7 @@ import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.SyncLossMessage;
import io.github.dsheirer.module.decode.DecoderType;
@@ -44,17 +42,21 @@ import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.text.DecimalFormat;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* APCO25 Phase 1 Linear Simulcast Modulation (LSM) decoder. Decimates incoming sample buffers to as close as possible
* to 25 kHz for ~5 samples per symbol. Employs baseband and pulse shaping filters. Incorporates an demodulator to
* process complex baseband (I/Q) sample stream into soft symbols with soft sync detection and message framing. A
* registered message listener receives the detected and framed messages.
* APCO25 Phase 1 Linear Simulcast Modulation (LSM) decoder. Decimates incoming sample buffers to 4-8 samples per
* symbol. Employs a flat baseband (channel) filter without a matched (root raised cosine) filter since the differential
* demodulator is a limiter-discriminator receiver where narrowing the bandwidth ahead of the discriminator distorts the
* phase trajectory and degrades performance (see BASEBAND_FILTER_PASS_BAND). The differential demodulator
* (mDemodulator) removes the coarse carrier offset and differentially demodulates the filtered complex baseband (I/Q)
* sample stream into the phase change across each symbol period, in the same radians per sample domain as the C4FM
* decoder. This allows the LSM decoder to share the C4FM symbol processor (mSymbolProcessor) that performs sync
* detection, timing correction, equalization and balance (PLL) residual mistune correction. Message framer performs
* message framing and message creation, using the demodulator's soft symbol reliabilities to weight soft decision
* decoding. A registered message listener receives the detected and framed messages.
*
* As a child of the FeedbackDecoder, this decoder provides periodic PLL measurements to the tuner for automatic PPM
* correction. It also provides a stream of demodulated soft symbols (in radians) for display to the user.
@@ -64,18 +66,23 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
{
private static final Logger LOGGER = LoggerFactory.getLogger(P25P1DecoderLSM.class);
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.##");
private static final Map<Double,float[]> BASEBAND_FILTERS = new HashMap<>();
//Baseband filter settings. Testing against LSM recordings: a 7250 Hz pass band passes adjacent channel
//interference and a matched (4800 baud root raised cosine) filter increases symbol errors. Without injected noise,
//flat pass bands from 4800 to 5600 Hz perform best, while narrower pass bands perform better with injected noise.
//A 4400 Hz pass band balances both: relative to 5200 Hz, 0.3% fewer valid messages without injected noise and
//25-50% more valid messages at 3-0 dB carrier to noise.
private static final double BASEBAND_FILTER_PASS_BAND = 4400.0; //Hertz
private static final double BASEBAND_FILTER_STOP_BAND = 5600.0; //Hertz
private static final int SYMBOL_RATE = 4800;
private final P25P1DemodulatorLSM mDemodulator;
private final P25P1SymbolProcessorLSM mSymbolProcessor;
private final P25P1DifferentialDemodulatorLSM mDemodulator = new P25P1DifferentialDemodulatorLSM();
private final P25P1MessageFramer mMessageFramer = new P25P1MessageFramer();
private final P25P1MessageProcessor mMessageProcessor = new P25P1MessageProcessor();
private IRealDecimationFilter mDecimationFilterI;
private IRealDecimationFilter mDecimationFilterQ;
private IRealFilter mBasebandFilterI;
private IRealFilter mBasebandFilterQ;
private IRealFilter mPulseShapingFilterI;
private IRealFilter mPulseShapingFilterQ;
@Override
public DecoderType getDecoderType()
@@ -83,10 +90,14 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
return DecoderType.P25_PHASE1;
}
/**
* Constructs an instance
*/
public P25P1DecoderLSM()
{
mMessageProcessor.setMessageListener(getMessageListener());
mDemodulator = new P25P1DemodulatorLSM(mMessageFramer, this);
mMessageFramer.setPhaseWrappedSoftSymbols(true);
mSymbolProcessor = new P25P1SymbolProcessorLSM(this, mMessageFramer);
}
@Override
@@ -111,35 +122,27 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
//Identify decimation that gets as close to 4.0 Samples Per Symbol as possible (4800 x 4.0 = 19.2 kHz)
while((sampleRate / decimation) >= 38400)
// while((sampleRate / decimation) > 50000)
{
decimation *= 2;
}
try
{
mDecimationFilterI = DecimationFilterFactory.getRealDecimationFilter(decimation);
mDecimationFilterQ = DecimationFilterFactory.getRealDecimationFilter(decimation);
}
catch(Exception e)
{
LOGGER.error("Error getting decimation filter for sample rate [" + sampleRate + "] decimation [" + decimation + "]");
}
mDecimationFilterI = DecimationFilterFactory.getRealDecimationFilter(decimation);
mDecimationFilterQ = DecimationFilterFactory.getRealDecimationFilter(decimation);
float decimatedSampleRate = (float)sampleRate / decimation;
//Set the decimated sample rate to use for PLL error reporting.
setDecimatedSampleRate(decimatedSampleRate);
int symbolLength = 16;
float rolloff = 0.2f;
float[] basebandTaps = FilterFactory.getRemezLowPass(decimatedSampleRate, BASEBAND_FILTER_PASS_BAND,
BASEBAND_FILTER_STOP_BAND);
mBasebandFilterI = FilterFactory.getRealFilter(basebandTaps);
mBasebandFilterQ = FilterFactory.getRealFilter(basebandTaps);
float samplesPerSymbol = decimatedSampleRate / (float)SYMBOL_RATE;
mDemodulator.setSamplesPerSymbol(samplesPerSymbol);
mSymbolProcessor.setSamplesPerSymbol(samplesPerSymbol);
float[] taps = FilterFactory.getRootRaisedCosine(decimatedSampleRate / 4800.0, symbolLength, rolloff);
mPulseShapingFilterI = new RealFIRFilter(taps);
mPulseShapingFilterQ = new RealFIRFilter(taps);
mBasebandFilterI = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
mBasebandFilterQ = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
mDemodulator.setSamplesPerSymbol(decimatedSampleRate / (float)SYMBOL_RATE);
mMessageFramer.setListener(mMessageProcessor);
}
@@ -162,49 +165,25 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
i = mBasebandFilterI.filter(i);
q = mBasebandFilterQ.filter(q);
i = mPulseShapingFilterI.filter(i);
q = mPulseShapingFilterQ.filter(q);
//Differential demodulation: phase change across each symbol period (radians per sample)
float[] demodulated = mDemodulator.demodulate(i, q, mSymbolProcessor.isSynchronized());
//Demodulate samples into symbols with timing, sync detection, and message framing.
mDemodulator.process(i, q);
//Process demodulated samples into symbols and apply message sync detection and framing. The soft symbol
//reliabilities weight the soft decision decoding.
mSymbolProcessor.process(demodulated, mDemodulator.getReliabilities());
}
/**
* Constructs a baseband filter for this decoder using the current sample rate
* Includes the coarse carrier offset that the differential demodulator removes ahead of the symbol processor,
* since the symbol processor's PLL error only represents the residual offset. The symbol processor invokes this
* method when the message framer decodes a valid NID, which also verifies the demodulator's carrier offset range.
* @param pllError residual carrier offset (radians per sample) from the symbol processor
*/
private float[] getBasebandFilter(double sampleRate)
@Override
public void processPLLError(float pllError)
{
if(BASEBAND_FILTERS.containsKey(sampleRate))
{
return BASEBAND_FILTERS.get(sampleRate);
}
FIRFilterSpecification specification = FIRFilterSpecification
.lowPassBuilder()
.sampleRate(sampleRate)
.passBandCutoff(7250)
.passBandAmplitude(1.0).passBandRipple(0.01) //.01
.stopBandAmplitude(0.0).stopBandStart(8000) //6500
.stopBandRipple(0.01).build();
float[] coefficients = null;
try
{
coefficients = FilterFactory.getTaps(specification);
BASEBAND_FILTERS.put(sampleRate, coefficients);
}
catch(Exception fde) //FilterDesignException
{
System.out.println("Error");
}
if(coefficients == null)
{
throw new IllegalStateException("Unable to design low pass filter for sample rate [" + sampleRate + "]");
}
return coefficients;
mDemodulator.offsetVerified();
super.processPLLError(pllError - mDemodulator.getFrequencyOffset());
}
/**
@@ -213,7 +192,7 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
@Override
public void setBufferListener(Listener<ByteBuffer> listener)
{
mDemodulator.setBufferListener(listener);
mSymbolProcessor.setBufferListener(listener);
}
/**
@@ -222,7 +201,7 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
@Override
public void removeBufferListener(Listener<ByteBuffer> listener)
{
mDemodulator.setBufferListener(null);
setBufferListener(null);
}
/**
@@ -231,7 +210,7 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
@Override
public boolean hasBufferListeners()
{
return mDemodulator.hasBufferListener();
return mSymbolProcessor.hasBufferListener();
}
@Override
@@ -252,6 +231,7 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
{
super.stop();
mMessageFramer.stop();
mSymbolProcessor.dispose();
}
/**
@@ -262,8 +242,13 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
switch(sourceEvent.getEvent())
{
case NOTIFICATION_FREQUENCY_CHANGE:
mSymbolProcessor.resetBalance();
mDemodulator.reset();
break;
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
mDemodulator.resetPLL();
//Tuner PPM changes are small (auto-PPM steps are <= 10 Hz) and the carrier offset estimator and symbol
//processor balance track the change. Resetting would discard the carrier offset estimate and force an
//offset ambiguity search when the offset exceeds the +/- 600 Hz fourth power estimator range.
break;
case NOTIFICATION_SAMPLE_RATE_CHANGE:
setSampleRate(sourceEvent.getValue().doubleValue());
@@ -282,17 +267,17 @@ public class P25P1DecoderLSM extends FeedbackDecoder implements IByteBufferProvi
LOGGER.info("Starting ...");
// String directory = "D:\\DQPSK Equalizer Research - P25\\"; //Windows
String directory = "/media/denny/T9/DQPSK Equalizer Research - P25/"; //Linux
String directory = "/run/media/denny/T9/DQPSK Equalizer Research - P25/"; //Linux
// String file = directory + "P25-S2-LSM-20241225_040119_460500000_CNYICC_Onondaga_Onondaga_CC_0_baseband.wav";
// String file = directory + "P25-S4-LSM-TCH-Data-20250105_141051_453587500_CNYICC_Onondaga_T-Onondaga_CC_38_baseband.wav";
// String file = directory + "P25-S5-LSM-CCH-20250923_034304_460500000_CNYICC_Onondaga_Onondaga-CC_0_baseband.wav";
String file = directory + "P25-S6-LSM-CCH-20251025_071546_460500000_CNYICC_Onondaga_Onondaga-CC_0_baseband.wav";
// String file = directory + "P25-S3-C4FM-20241225_040459_152517500_NYSEG_Onondaga_Control_30_baseband.wav";
// String file = directory + "P25-S6-LSM-CCH-20251025_071546_460500000_CNYICC_Onondaga_Onondaga-CC_0_baseband.wav";
// String file = directory + "P25-S7-LSM-CCH-20261027_030041_460475000_Central-New-York-Interoperable-Communications-Consortium-(CNYICC)_Madison-Simulcast_Control_51_baseband.wav";
// String file = directory + "P25-S8-LSM-TCH-20261027_030138_460250000_Central-New-York-Interoperable-Communications-Consortium-(CNYICC)_Madison-Simulcast_T-Control_52_baseband.wav";
// String file = directory + "P25-S9-LSM-TCH-20261027_030248_460175000_Central-New-York-Interoperable-Communications-Consortium-(CNYICC)_Madison-Simulcast_T-Control_53_baseband.wav";
// String file = directory + "P25-S10-LSM-TCH-20261027_031432_460175000_Central-New-York-Interoperable-Communications-Consortium-(CNYICC)_Madison-Simulcast_T-Control_11_baseband.wav";
String file = directory + "P25-S11-LSM-TCH-20261027_031603_460250000_Central-New-York-Interoperable-Communications-Consortium-(CNYICC)_Madison-Simulcast_T-Control_12_baseband.wav";
boolean autoReplay = false;
P25P1DecoderLSM decoder = new P25P1DecoderLSM();

View File

@@ -1,391 +0,0 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.filter.interpolator.LinearInterpolator;
import io.github.dsheirer.dsp.symbol.Dibit;
import io.github.dsheirer.dsp.symbol.DibitToByteBufferAssembler;
import io.github.dsheirer.gui.viewer.symbol.SymbolViewerFX;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.sample.Listener;
import java.nio.ByteBuffer;
import java.util.Arrays;
/**
* Demodulates filtered LSM I/Q samples to feed message framer for sync detection and framing.
*/
public class P25P1DemodulatorLSM
{
private static final float HALF_PI = (float)(Math.PI / 2.0);
private static final float TWO_PI = (float)(Math.PI * 2.0);
private static final float MAX_PLL = (float)(Math.PI / 3.0); //+/- 800 Hz
private static final float OBJECTIVE_MAGNITUDE = 1.0f;
private final DibitToByteBufferAssembler mDibitAssembler = new DibitToByteBufferAssembler(300);
private final FeedbackDecoder mFeedbackDecoder;
private final P25P1MessageFramer mMessageFramer;
private SymbolViewerFX mDebugSymbolViewer;
private double mSamplePoint;
private double mSamplesPerSymbol;
private double mSamplesPerHalfSymbol;
private float[] mBufferI;
private float[] mBufferQ;
private float mPLL = 0f;
private float mSampleGain = 1f;
private float mPreviousMiddleI, mPreviousMiddleQ, mPreviousCurrentI, mPreviousCurrentQ, mPreviousSymbolI = 0.7f, mPreviousSymbolQ = 0.7f;
private int mBufferPointer;
private int mBufferReserve;
/**
* Constructs an instance
* @param messageFramer for receiving demodulated symbol stream and providing sync detection events.
* @param feedbackDecoder (parent) for receiving PLL carrier offset error reports.
*/
public P25P1DemodulatorLSM(P25P1MessageFramer messageFramer, FeedbackDecoder feedbackDecoder)
{
mMessageFramer = messageFramer;
mFeedbackDecoder = feedbackDecoder;
}
/**
* Reset the PLL when the tuner source either changes center frequency or adjust the PPM value.
*/
public void resetPLL()
{
mPLL = 0f;
}
/**
* Debug utility for viewing I/Q waveform, demodulated waveform and symbol decision details, sequentially.
*/
protected SymbolViewerFX getDebugSymbolViewer()
{
if(mDebugSymbolViewer == null)
{
mDebugSymbolViewer = new SymbolViewerFX();
}
return mDebugSymbolViewer;
}
/**
* Primary input method for receiving a stream of filtered, pulse-shaped samples to process into symbols.
* @param i inphase samples to process
* @param q quadrature samples to process
*/
public void process(float[] i, float[] q)
{
//Shadow copy heap member variables onto the stack
double samplePoint = mSamplePoint;
double samplesPerSymbol = mSamplesPerSymbol;
double samplesPerHalfSymbol = mSamplesPerHalfSymbol;
float pll = mPLL;
float previousSymbolI = mPreviousSymbolI;
float previousSymbolQ = mPreviousSymbolQ;
float previousMiddleI = mPreviousMiddleI;
float previousMiddleQ = mPreviousMiddleQ;
float previousCurrentI = mPreviousCurrentI;
float previousCurrentQ = mPreviousCurrentQ;
float sampleGain = mSampleGain;
int bufferPointer = mBufferPointer;
float pllGain = 0.1f;
double tedGain = samplesPerSymbol / 4;
double maxTimingAdjustment = samplesPerSymbol / 25;
double pointer, residual, timingAdjustment;
float magnitude, phaseError = 0, requiredGain, softSymbol, pllI, pllQ, pllTemp;
float iMiddle, qMiddle, iCurrent, qCurrent, iMiddleDemodulated, qMiddleDemodulated, iSymbol, qSymbol;
int offset;
Dibit hardSymbol;
//I/Q buffers are the same length as incoming samples padded by an extra symbol length for processing space.
int bufferReserve = mBufferReserve;
int requiredLength = i.length + bufferReserve;
if(mBufferI.length != requiredLength)
{
mBufferI = Arrays.copyOf(mBufferI, requiredLength);
mBufferQ = Arrays.copyOf(mBufferQ, requiredLength);
}
//Transfer extra reserve samples from last processing iteration to the front of the I/Q buffers
System.arraycopy(mBufferI, bufferPointer, mBufferI, 0, bufferReserve);
System.arraycopy(mBufferQ, bufferPointer, mBufferQ, 0, bufferReserve);
//Copy incoming I/Q samples to the processing buffers
System.arraycopy(i, 0, mBufferI, bufferReserve, i.length);
System.arraycopy(q, 0, mBufferQ, bufferReserve, q.length);
bufferPointer = 0;
int bufferReload = mBufferI.length - bufferReserve;
while(bufferPointer < bufferReload)
{
bufferPointer++;
samplePoint--;
if(samplePoint < 1)
{
//Sample point is the middle sample, between the previous and current symbols.
iMiddle = LinearInterpolator.calculate(mBufferI[bufferPointer], mBufferI[bufferPointer + 1], samplePoint);
qMiddle = LinearInterpolator.calculate(mBufferQ[bufferPointer], mBufferQ[bufferPointer + 1], samplePoint);
//Calculate offset to next symbol.
pointer = bufferPointer + samplePoint + samplesPerHalfSymbol;
offset = (int)Math.floor(pointer);
residual = pointer - offset;
iCurrent = LinearInterpolator.calculate(mBufferI[offset], mBufferI[offset + 1], residual);
qCurrent = LinearInterpolator.calculate(mBufferQ[offset], mBufferQ[offset + 1], residual);
//Adjust sample gain based on highest magnitude and apply to both middle and current samples.
magnitude = (float)(Math.sqrt(Math.pow(iCurrent, 2.0) + Math.pow(qCurrent, 2.0)));
if(magnitude > 0 && !Float.isInfinite(magnitude))
{
requiredGain = constrain(OBJECTIVE_MAGNITUDE / magnitude, 500);
sampleGain += (requiredGain - sampleGain) * .05f;
sampleGain = Math.min(sampleGain, requiredGain);
sampleGain = Math.min(sampleGain, 500);
}
//Apply gain to the samples
iMiddle *= sampleGain;
qMiddle *= sampleGain;
iCurrent *= sampleGain;
qCurrent *= sampleGain;
//Create I/Q representation of current PLL state
pllI = (float)Math.cos(pll);
pllQ = (float)Math.sin(pll);
//Differential demodulation of middle sample
iMiddleDemodulated = (previousMiddleI * iMiddle) - (-previousMiddleQ * qMiddle);
qMiddleDemodulated = (previousMiddleI * qMiddle) + (-previousMiddleQ * iMiddle);
//Rotate middle sample by the PLL offset
pllTemp = (iMiddleDemodulated * pllI) - (qMiddleDemodulated * pllQ);
qMiddleDemodulated = (qMiddleDemodulated * pllI) + (iMiddleDemodulated * pllQ);
iMiddleDemodulated = pllTemp;
//Differential demodulation of symbol
iSymbol = (previousCurrentI * iCurrent) - (-previousCurrentQ * qCurrent);
qSymbol = (previousCurrentI * qCurrent) + (-previousCurrentQ * iCurrent);
//Rotate symbol by the PLL offset
pllTemp = (iSymbol * pllI) - (qSymbol * pllQ);
qSymbol = (qSymbol * pllI) + (iSymbol * pllQ);
iSymbol = pllTemp;
//Calculate the symbol (radians) from the I/Q values
softSymbol = (float)Math.atan2(qSymbol, iSymbol);
//Apply Gardner timing error detection and correction
timingAdjustment = ((previousSymbolI - iSymbol) * iMiddleDemodulated) + ((previousSymbolQ - qSymbol) * qMiddleDemodulated);
timingAdjustment = constrain(timingAdjustment, maxTimingAdjustment);
timingAdjustment *= tedGain;
samplePoint += timingAdjustment;
//Phase Locked Loop adjustment. Note: don't adjust PLL when soft symbol value is zero.
if(softSymbol != 0)
{
hardSymbol = toDibit(softSymbol);
phaseError = constrain(softSymbol - hardSymbol.getIdealPhase(), .3f);
pll -= (phaseError * pllGain);
pll = constrain(pll, MAX_PLL);
}
else
{
hardSymbol = Dibit.D00_PLUS_1;
}
//Message framer returns boolean if valid sync and NID were detected/decoded. Send a PLL measured
//error report up to the tuner source so that it can self correct for mistuned channel and also to
//display the measured carrier offset value in the channel display
if(mMessageFramer.processWithSoftSyncDetect(softSymbol, hardSymbol))
{
mFeedbackDecoder.processPLLError(pll);
}
mDibitAssembler.receive(hardSymbol);
mFeedbackDecoder.broadcast(softSymbol);
// if(bufferPointer >= 15)
// {
// SymbolViewerFX viewer = getDebugSymbolViewer();
// CountDownLatch latch = new CountDownLatch(1);
// float[] rawI = Arrays.copyOfRange(mBufferI, bufferPointer - 15, bufferPointer + 15);
// float[] rawQ = Arrays.copyOfRange(mBufferQ, bufferPointer - 15, bufferPointer + 15);
// double middle = 15 + samplePoint;// - timingAdjustment;
// double previous = middle - samplesPerHalfSymbol;
// double current = middle + samplesPerHalfSymbol;
// double[] points = {previous, middle, current, //Timing
// previousCurrentI, iMiddle, iCurrent, //RawI
// previousCurrentQ, qMiddle, qCurrent, //RawQ
// previousSymbolI, iMiddleDemodulated, iSymbol, //DemodI
// previousSymbolQ, qMiddleDemodulated, qSymbol}; //DemodQ
// float middleSymbol = normalize((float)Math.atan2(qMiddleDemodulated, iMiddleDemodulated) + pll);
// float[] symbols = {previousSymbol, middleSymbol, softSymbol};
// float adjustedPLL = pll + (phaseError * pllGain);
// viewer.receive(samplesPerSymbol, rawI, rawQ, sampleGain, adjustedPLL, points, symbols, latch);
// try
// {
// latch.await();
// }
// catch(InterruptedException e)
// {
// throw new RuntimeException(e);
// }
// previousSymbol = softSymbol;
// }
//Shuffle the values
previousSymbolI = iSymbol;
previousSymbolQ = qSymbol;
previousMiddleI = iMiddle;
previousMiddleQ = qMiddle;
previousCurrentI = iCurrent;
previousCurrentQ = qCurrent;
//Add another symbol's worth of samples to the counter
samplePoint += samplesPerSymbol;
}
}
//Copy shadow variables back to member variables.
mPLL = pll;
mBufferPointer = bufferPointer;
mPreviousMiddleI = previousMiddleI;
mPreviousMiddleQ = previousMiddleQ;
mPreviousCurrentI = previousCurrentI;
mPreviousCurrentQ = previousCurrentQ;
mPreviousSymbolI = previousSymbolI;
mPreviousSymbolQ = previousSymbolQ;
mSampleGain = sampleGain;
mSamplePoint = samplePoint;
}
/**
* Normalizes the value in radians to the range of +/-(2 * PI)
* @param value to normalize
* @return normalized value.
*/
private static float normalize(float value)
{
if(value > Math.PI)
{
return value - TWO_PI;
}
else if(value < -Math.PI)
{
return value + TWO_PI;
}
return value;
}
/**
* Constrains value to range: -constraint to constrain
* @param value to constrain
* @param constraint as max positive value
* @return constrained value.
*/
private static double constrain(double value, double constraint)
{
if(Double.isNaN(value) || Double.isInfinite(value))
{
return 0.0;
}
else if(value > constraint)
{
return constraint;
}
return Math.max(value, -constraint);
}
/**
* Constrains value to range: -constraint to constrain
* @param value to constrain
* @param constraint as max positive value
* @return constrained value.
*/
private static float constrain(float value, float constraint)
{
if(Float.isNaN(value) || Float.isInfinite(value))
{
return 0.0f;
}
if(value > constraint)
{
return constraint;
}
return Math.max(value, -constraint);
}
/**
* Sets or updates the samples per symbol
* @param samplesPerSymbol to apply.
*/
public void setSamplesPerSymbol(float samplesPerSymbol)
{
mSamplesPerSymbol = samplesPerSymbol;
mSamplesPerHalfSymbol = samplesPerSymbol / 2.0f;
mSamplePoint = samplesPerSymbol;
mBufferReserve = (int)Math.ceil(samplesPerSymbol);
mBufferI = new float[mBufferReserve];
mBufferQ = new float[mBufferReserve];
mBufferPointer = 0;
}
/**
* Registers the listener to receive demodulated bit stream buffers.
* @param listener to register
*/
public void setBufferListener(Listener<ByteBuffer> listener)
{
mDibitAssembler.setBufferListener(listener);
}
/**
* Indicates if there is a registered buffer listener
*/
public boolean hasBufferListener()
{
return mDibitAssembler.hasBufferListeners();
}
/**
* Decodes the sample value to determine the correct QPSK quadrant and maps the value to a Dibit symbol.
* @param softSymbol in radians.
* @return symbol decision.
*/
public static Dibit toDibit(float softSymbol)
{
if(softSymbol > 0)
{
return softSymbol > HALF_PI ? Dibit.D01_PLUS_3 : Dibit.D00_PLUS_1;
}
else
{
return softSymbol < -HALF_PI ? Dibit.D11_MINUS_3 : Dibit.D10_MINUS_1;
}
}
}

View File

@@ -0,0 +1,371 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
/**
* P25 Phase 1 LSM (CQPSK) differential demodulator that produces a real valued sample stream for the C4FM symbol
* processor. Each output sample is the phase change across the preceding symbol period, in radians per sample (ie
* the symbol phase divided by the samples per symbol), which is the same domain as the C4FM decoder's FM demodulated
* and receive filtered sample stream.
*
* Processing steps:
*
* 1. FM discriminator (atan2) produces the phase change between consecutive samples. A full range (atan2)
* discriminator is required because the LSM signal trajectory passes near the origin between symbols where the phase
* change between samples can exceed PI/2, which would fold with an atan(Q/I) discriminator.
* 2. Coarse frequency offset removal: subtracts the estimated carrier offset from each discriminator sample.
* 3. One symbol period integrator: the moving sum of the discriminator output is the phase change across the symbol
* period: arg(x[n] * conj(x[n - samplesPerSymbol])), modulo 2 * PI.
* 4. Phase wrap to +/- PI. The moving sum is only correct modulo 2 * PI since a discriminator sample near the origin
* can be wrapped by 2 * PI, and that error persists across the integrator span.
* 5. Carrier offset estimation: the fourth power of the differential QPSK symbols removes the modulation so that the
* residual phase is four times the carrier offset (radians per symbol). This is a constellation domain estimator,
* which isn't biased by the spectral tilt of a frequency selective (ie simulcast multipath) channel the way that a
* spectral centroid (ie mean discriminator output) estimator is.
*
* The fourth power estimator is ambiguous modulo PI/2 radians per symbol (1200 Hz at 4800 baud), so the estimator only
* resolves offsets within +/- 600 Hz. When a signal is present (ie the fourth power coherence exceeds the noise
* threshold) but the symbol processor is unable to synchronize, the estimate is stepped by +/- PI/2 radians per symbol
* to search the adjacent offset ranges. An offset just beyond the range aliases to an estimate near the opposite edge
* of the range (ie -650 Hz estimates as +550 Hz), so the search first steps toward the adjacent range nearest to the
* estimate (ie -1200 Hz for a positive estimate) and then the other adjacent range. The fourth power coherence is the
* same at each ambiguous offset, so high coherence without synchronization indicates an incorrect offset range, while
* low coherence indicates that there is no signal. The search holds the current offset range while there is no signal
* (ie between transmissions on a traffic channel) so that the next transmission starts with the offset range that last
* synchronized. Once an offset range is verified by a valid NID decode (see offsetVerified()), the search uses a
* longer dwell period before stepping away from it. Symbol processor synchronization alone doesn't verify the offset
* range since a false sync detection in an incorrect offset range would otherwise hold the search in that range for the
* longer dwell period.
*
* Soft symbol reliability: the demodulated phase change across the symbol period is the difference between the phases
* of the samples at either end of the period, and the phase noise variance of each sample is inversely proportional to
* its power, so the noise variance of the phase change is proportional to 1/|x[n]|^2 + 1/|x[n - samplesPerSymbol]|^2.
* The reliability (inverse noise variance) of each demodulated sample, (|x[n]|^2 * |x[n - samplesPerSymbol]|^2) /
* (|x[n]|^2 + |x[n - samplesPerSymbol]|^2), weights the soft decision decoding (see getReliabilities()). The LSM
* signal envelope passes near the origin between symbols and simulcast multipath produces deep envelope fades, so the
* symbol error rate varies by orders of magnitude with the envelope. Testing against a simulcast control channel
* recording found that 60% of the symbol errors occur in the 15% of the symbols with the lowest envelope.
*
* Since the carrier offset is removed ahead of the symbol processor, the symbol processor's balance (PLL) value only
* represents the residual offset. Use getFrequencyOffset() to include the coarse offset when reporting the carrier
* offset to the tuner.
*/
public class P25P1DifferentialDemodulatorLSM
{
private static final double TWO_PI = 2.0 * Math.PI;
//Carrier offset ambiguity step of the fourth power estimator, in radians per symbol
private static final double AMBIGUITY_STEP = Math.PI / 2.0;
//The estimator, signal presence detection and ambiguity search update once per block of symbols, independent of
//the sample buffer size. The coherence (magnitude of the averaged unit vectors) of noise depends on the number of
//samples averaged, so a fixed block length also keeps the signal presence threshold independent of buffer size.
private static final int ESTIMATOR_BLOCK_SYMBOLS = 200;
//Coarse offset estimator loop gain applied to each block's fourth power phase error while the symbol processor is
//synchronized (~6,600 symbol time constant)
private static final double ESTIMATOR_GAIN = 0.03;
//Estimator loop gain while the symbol processor is unsynchronized, so that the estimate converges within the
//ambiguity search dwell period. With the tracking gain, the estimate converged over several dwell periods and an
//offset near the edge of the estimator range (ie -550 Hz) took longer to acquire than offsets beyond the range.
private static final double ESTIMATOR_ACQUISITION_GAIN = 0.2;
//Block coherence at and above which the estimator applies the full loop gain
private static final double ESTIMATOR_FULL_GAIN_COHERENCE = 0.3;
//Minimum average fourth power coherence (magnitude of the averaged unit vectors) to detect signal presence, to update
//the estimate and to advance the ambiguity search. LSM recordings produce values above 0.3 without injected noise
//and average 0.11-0.14 at 0 dB carrier to noise (50 kHz). Single block coherence values for noise reach 0.10, so
//the coherence is averaged across blocks, where the maximum noise value is 0.041 across 10 minutes of noise.
private static final double SIGNAL_MINIMUM_COHERENCE = 0.07;
//Coherence averaging gain, applied per block (~10 blocks or ~2,000 symbols)
private static final double COHERENCE_AVERAGING_GAIN = 0.1;
//Symbol count with a signal present but without synchronization before stepping to the next ambiguity search offset
private static final int SEARCH_DWELL_SYMBOLS = 2000;
//Symbol count with a signal present but without synchronization before stepping away from an offset range that
//was verified (~5 seconds). At low SNR, sync is frequently lost and reacquired, and the shorter search dwell caused
//the search to spend half of the time in an incorrect offset range at 0 dB carrier to noise.
private static final int VERIFIED_DWELL_SYMBOLS = 24000;
//Ambiguity search offsets, as multiples of the ambiguity step, relative to the estimator's offset. The search
//direction sets the sign of the adjacent range steps.
private static final int[] SEARCH_STEPS = {0, 1, -1};
private IRealFilter mIntegrator;
//Sample power history spanning the reliability symbol period delay, carried across sample buffers
private float[] mPowerHistory = new float[1];
private float[] mReliabilities = new float[0];
private double mSamplesPerSymbol = 1.0;
private float mPreviousI;
private float mPreviousQ;
//Fourth power estimator offset, radians per symbol
private double mEstimatorOffset = 0.0;
private int mSearchIndex = 0;
private int mSearchDirection = 1;
private double mUnsynchronizedSignalSymbols = 0;
//Indicates the current search offset range was verified by a valid NID decode since it was selected
private boolean mSearchIndexVerified = false;
private double mAverageCoherence = 0.0;
//Block length in samples and the fourth power accumulation for the block under accumulation
private int mBlockLength = ESTIMATOR_BLOCK_SYMBOLS;
private int mBlockPointer = 0;
private double mBlockI = 0.0;
private double mBlockQ = 0.0;
/**
* Constructs an instance
*/
public P25P1DifferentialDemodulatorLSM()
{
}
/**
* Sets or updates the samples per symbol.
* @param samplesPerSymbol for the incoming sample stream
*/
public void setSamplesPerSymbol(double samplesPerSymbol)
{
mSamplesPerSymbol = samplesPerSymbol;
mIntegrator = new RealFIRFilter(FilterFactory.getDifferentialIntegrator(samplesPerSymbol));
mPowerHistory = new float[Math.max(1, (int)Math.round(samplesPerSymbol))];
mBlockLength = (int)Math.round(ESTIMATOR_BLOCK_SYMBOLS * samplesPerSymbol);
resetBlock();
}
/**
* Resets the carrier offset estimate after a tuner center frequency change.
*/
public void reset()
{
mEstimatorOffset = 0.0;
mSearchIndex = 0;
mSearchDirection = 1;
mUnsynchronizedSignalSymbols = 0;
mSearchIndexVerified = false;
mAverageCoherence = 0.0;
resetBlock();
}
/**
* Resets the fourth power accumulation for the block under accumulation.
*/
private void resetBlock()
{
mBlockPointer = 0;
mBlockI = 0.0;
mBlockQ = 0.0;
}
/**
* Notification that a valid NID was decoded, verifying the current carrier offset range so that the ambiguity search
* uses the longer dwell period before stepping away from it.
*/
public void offsetVerified()
{
mSearchIndexVerified = true;
}
/**
* Carrier offset (coarse) removed by this demodulator.
* @return offset in radians per sample.
*/
public float getFrequencyOffset()
{
return (float)(getOffset() / mSamplesPerSymbol);
}
/**
* Soft symbol reliabilities for each of the samples produced by the most recent demodulate() call.
* @return reliabilities (inverse of the relative phase noise variance), parallel to the demodulated samples.
*/
public float[] getReliabilities()
{
return mReliabilities;
}
/**
* Current total offset including the ambiguity search step.
* @return offset in radians per symbol.
*/
private double getOffset()
{
return mEstimatorOffset + SEARCH_STEPS[mSearchIndex] * mSearchDirection * AMBIGUITY_STEP;
}
/**
* Demodulates the filtered complex samples.
* @param i inphase samples
* @param q quadrature samples
* @param processorSynchronized indicates if the symbol processor is currently synchronized to the signal, which
* controls the ambiguity search.
* @return differentially demodulated samples in radians per sample.
*/
public float[] demodulate(float[] i, float[] q, boolean processorSynchronized)
{
float[] discriminated = new float[i.length];
float previousI = mPreviousI;
float previousQ = mPreviousQ;
float demodulatedI, demodulatedQ;
for(int x = 0; x < i.length; x++)
{
//Multiply the current sample by the complex conjugate of the previous sample
demodulatedI = (i[x] * previousI) + (q[x] * previousQ);
demodulatedQ = (q[x] * previousI) - (i[x] * previousQ);
discriminated[x] = (float)Math.atan2(demodulatedQ, demodulatedI);
previousI = i[x];
previousQ = q[x];
}
mPreviousI = previousI;
mPreviousQ = previousQ;
mReliabilities = calculateReliabilities(i, q);
float[] demodulated = mIntegrator.filter(discriminated);
//Remove the carrier offset (the integrator has unity gain at DC, so the offset is removed after integration to
//apply each block's updated estimate), wrap each sample to +/- PI radians per symbol, and accumulate the fourth
//power of each symbol phase.
double samplesPerSymbol = mSamplesPerSymbol;
float wrap = (float)(TWO_PI / samplesPerSymbol);
float offset = getFrequencyOffset();
double theta;
for(int x = 0; x < demodulated.length; x++)
{
demodulated[x] -= offset;
demodulated[x] -= wrap * Math.round(demodulated[x] / wrap);
theta = 4.0 * demodulated[x] * samplesPerSymbol;
mBlockI += Math.cos(theta);
mBlockQ += Math.sin(theta);
if(++mBlockPointer >= mBlockLength)
{
updateEstimate(processorSynchronized);
offset = getFrequencyOffset();
}
}
return demodulated;
}
/**
* Updates the carrier offset estimate, the signal presence detection and the ambiguity search from the fourth power
* accumulation for the completed block.
* @param processorSynchronized state of the symbol processor
*/
private void updateEstimate(boolean processorSynchronized)
{
//Ideal symbols (+/- PI/4 and +/- 3PI/4) all map to PI in the fourth power, so the residual angle is four times
//the residual carrier offset (radians per symbol).
double coherence = Math.sqrt(mBlockI * mBlockI + mBlockQ * mBlockQ) / mBlockPointer;
mAverageCoherence += (coherence - mAverageCoherence) * COHERENCE_AVERAGING_GAIN;
boolean signalPresent = mAverageCoherence > SIGNAL_MINIMUM_COHERENCE;
if(signalPresent)
{
//Weight the update by this block's coherence so that noise blocks (ie after a transmission ends, while the
//average coherence decays) don't move the estimate.
double error = Math.atan2(-mBlockQ, -mBlockI) / 4.0;
double weight = Math.min(1.0, coherence / ESTIMATOR_FULL_GAIN_COHERENCE);
double gain = processorSynchronized ? ESTIMATOR_GAIN : ESTIMATOR_ACQUISITION_GAIN;
mEstimatorOffset += error * gain * weight;
//Constrain the estimator to the fundamental range. The ambiguity search covers the adjacent ranges.
mEstimatorOffset = Math.clamp(mEstimatorOffset, -AMBIGUITY_STEP / 2, AMBIGUITY_STEP / 2);
}
updateSearch(mBlockPointer, processorSynchronized, signalPresent);
resetBlock();
}
/**
* Calculates the reliability of each demodulated sample from the power of the samples at either end of the symbol
* period: (|x[n]|^2 * |x[n - samplesPerSymbol]|^2) / (|x[n]|^2 + |x[n - samplesPerSymbol]|^2)
* @param i inphase samples
* @param q quadrature samples
* @return reliabilities
*/
private float[] calculateReliabilities(float[] i, float[] q)
{
float[] history = mPowerHistory;
int delay = history.length;
float[] power = new float[i.length];
float[] reliabilities = new float[i.length];
float current, delayed, sum;
for(int x = 0; x < i.length; x++)
{
power[x] = i[x] * i[x] + q[x] * q[x];
}
for(int x = 0; x < i.length; x++)
{
current = power[x];
delayed = x >= delay ? power[x - delay] : history[x];
sum = current + delayed;
reliabilities[x] = sum > 0.0f ? current * delayed / sum : 0.0f;
}
//Retain the final (delay) sample powers for the next buffer, shifting forward when the buffer is short
if(i.length >= delay)
{
System.arraycopy(power, i.length - delay, history, 0, delay);
}
else
{
System.arraycopy(history, i.length, history, 0, delay - i.length);
System.arraycopy(power, 0, history, delay - i.length, i.length);
}
return reliabilities;
}
/**
* Steps the ambiguity search offset when a signal is present and the symbol processor hasn't synchronized within
* the dwell period. Holds the current search offset while there is no signal.
* @param sampleCount in the completed block
* @param processorSynchronized state of the symbol processor
* @param signalPresent indicates if a signal is present
*/
private void updateSearch(int sampleCount, boolean processorSynchronized, boolean signalPresent)
{
if(processorSynchronized)
{
mUnsynchronizedSignalSymbols = 0;
return;
}
if(signalPresent)
{
mUnsynchronizedSignalSymbols += sampleCount / mSamplesPerSymbol;
if(mUnsynchronizedSignalSymbols > (mSearchIndexVerified ? VERIFIED_DWELL_SYMBOLS : SEARCH_DWELL_SYMBOLS))
{
mUnsynchronizedSignalSymbols = 0;
mSearchIndexVerified = false;
mSearchIndex = (mSearchIndex + 1) % SEARCH_STEPS.length;
//Leaving the estimator's range: step first toward the adjacent range nearest to the estimate
if(mSearchIndex == 1)
{
mSearchDirection = mEstimatorOffset > 0.0 ? -1 : 1;
}
}
}
}
}

View File

@@ -1,244 +0,0 @@
/*
* *****************************************************************************
* 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.p25.phase1;
/**
* Designs the P25 Phase 1 C4FM receive filter applied to the FM discriminator output.
*
* TIA-102.BAAA specifies a raised cosine (alpha 0.2) Nyquist filter cascaded with an inverse sinc (x / sin(x)) shaping
* filter at the transmitter, and an integrate and dump filter (sinc response) at the receiver, so that the overall
* response is the raised cosine with no intersymbol interference at the symbol sampling instants.
*
* The transmitted signal is band limited to (1 + alpha) * symbolRate / 2 (2880 Hz), but an integrate and dump filter
* has slowly decaying sidelobes that pass the FM discriminator noise above the signal band, where the discriminator
* noise power spectral density rises with the square of frequency. This designer produces a linear phase FIR filter
* with the integrate and dump (sinc) response across the signal band and a stopband above it, which preserves the zero
* ISI property while rejecting the out of band noise.
*/
public class P25P1FilterDesigner
{
/** P25 C4FM raised cosine roll-off factor. */
public static final double P25_ALPHA = 0.2;
/** Least-squares weight applied in the stopband, relative to a weight of 1.0 in the passband. */
private static final double STOPBAND_WEIGHT = 1.0;
/** Frequency grid points per filter coefficient used for the least-squares fit. */
private static final int GRID_DENSITY = 16;
/**
* Designs the band limited integrate and dump receive filter.
*
* @param sampleRate in Hertz
* @param symbolRate in symbols per second
* @param stopBandStart frequency in Hertz where the stopband starts. The passband extends to the signal band
* edge of (1 + alpha) * symbolRate / 2 and the region between the passband edge and the stopband start is a
* transition band that is excluded from the fit.
* @param symbolSpan filter length in symbol periods
* @return filter taps, odd length, linear phase, unity gain at DC
*/
public static float[] designReceiveFilter(double sampleRate, double symbolRate, double stopBandStart, int symbolSpan)
{
if(sampleRate <= 0 || symbolRate <= 0)
{
throw new IllegalArgumentException("Sample rate and symbol rate must be positive");
}
if(symbolSpan < 1)
{
throw new IllegalArgumentException("Symbol span must be at least 1");
}
double nyquist = sampleRate / 2.0;
double passBandEdge = (1.0 + P25_ALPHA) * symbolRate / 2.0;
if(stopBandStart <= passBandEdge || stopBandStart >= nyquist)
{
throw new IllegalArgumentException("Stop band start [" + stopBandStart + "] must be between the pass band " +
"edge [" + passBandEdge + "] and Nyquist [" + nyquist + "]");
}
//Odd tap count so the filter has an integer group delay of (length - 1) / 2 samples
int length = (int)Math.ceil(symbolSpan * sampleRate / symbolRate);
if(length % 2 == 0)
{
length++;
}
int middle = (length - 1) / 2;
int coefficientCount = middle + 1;
double symbolPeriod = 1.0 / symbolRate;
//A symmetric filter has the zero-phase response A(f) = c[0] + sum(m=1..middle) c[m] * 2cos(2*pi*f*m/fs)
//where c[m] = h[middle - m] = h[middle + m]. Accumulate the weighted normal equations (B'WB)c = B'Wd over a
//dense frequency grid from DC to Nyquist, skipping the transition band.
double[][] normal = new double[coefficientCount][coefficientCount];
double[] rhs = new double[coefficientCount];
double[] basis = new double[coefficientCount];
int gridSize = GRID_DENSITY * coefficientCount;
for(int g = 0; g <= gridSize; g++)
{
double frequency = nyquist * g / gridSize;
double desired;
double weight;
if(frequency <= passBandEdge)
{
desired = sinc(frequency * symbolPeriod);
weight = 1.0;
}
else if(frequency >= stopBandStart)
{
desired = 0.0;
weight = STOPBAND_WEIGHT;
}
else
{
continue; //Transition band
}
basis[0] = 1.0;
for(int m = 1; m < coefficientCount; m++)
{
basis[m] = 2.0 * Math.cos(2.0 * Math.PI * frequency * m / sampleRate);
}
for(int i = 0; i < coefficientCount; i++)
{
double weighted = weight * basis[i];
rhs[i] += weighted * desired;
for(int j = i; j < coefficientCount; j++)
{
normal[i][j] += weighted * basis[j];
}
}
}
//Fill in the lower triangle of the symmetric normal matrix
for(int i = 0; i < coefficientCount; i++)
{
for(int j = 0; j < i; j++)
{
normal[i][j] = normal[j][i];
}
}
double[] coefficients = solve(normal, rhs);
//Expand to the full symmetric impulse response and normalize to unity gain at DC
double[] taps = new double[length];
double sum = 0.0;
for(int m = 0; m < coefficientCount; m++)
{
taps[middle - m] = coefficients[m];
taps[middle + m] = coefficients[m];
}
for(double tap : taps)
{
sum += tap;
}
float[] output = new float[length];
for(int n = 0; n < length; n++)
{
output[n] = (float)(taps[n] / sum);
}
return output;
}
/**
* Sinc: sin(x) / x where x = pi * f * T, the frequency response of an integrate and dump filter of length T.
*
* @param normalizedFrequency f * T (frequency divided by the symbol rate)
*/
private static double sinc(double normalizedFrequency)
{
if(normalizedFrequency == 0.0)
{
return 1.0;
}
double x = Math.PI * normalizedFrequency;
return Math.sin(x) / x;
}
/**
* Solves Ax = b using Gaussian elimination with partial pivoting. A and b are modified.
*/
private static double[] solve(double[][] a, double[] b)
{
int n = b.length;
for(int column = 0; column < n; column++)
{
int pivot = column;
for(int row = column + 1; row < n; row++)
{
if(Math.abs(a[row][column]) > Math.abs(a[pivot][column]))
{
pivot = row;
}
}
double[] swapRow = a[column];
a[column] = a[pivot];
a[pivot] = swapRow;
double swapValue = b[column];
b[column] = b[pivot];
b[pivot] = swapValue;
for(int row = column + 1; row < n; row++)
{
double factor = a[row][column] / a[column][column];
b[row] -= factor * b[column];
for(int k = column; k < n; k++)
{
a[row][k] -= factor * a[column][k];
}
}
}
double[] x = new double[n];
for(int row = n - 1; row >= 0; row--)
{
double sum = b[row];
for(int k = row + 1; k < n; k++)
{
sum -= a[row][k] * x[k];
}
x[row] = sum / a[row][row];
}
return x;
}
}

View File

@@ -126,6 +126,7 @@ public class P25P1Interleave
*/
/**
* Deinterleaves the soft symbols for a 196-bit (98 dibit) data block (TSBK, PDU header, unconfirmed data block).
* Also deinterleaves any other per-dibit values, such as the soft symbol reliabilities.
* @param interleaved 98 soft symbols in transmitted order
* @return 98 soft symbols in deinterleaved order.
*/

View File

@@ -43,11 +43,19 @@ public class P25P1MessageAssembler implements Listener<Dibit>
private static final Logger LOGGER = LoggerFactory.getLogger(P25P1MessageAssembler.class);
private static final LoggingSuppressor LOGGING_SUPPRESSOR = new LoggingSuppressor(LOGGER);
private static final int MINIMUM_LDU_BIT_LENGTH = 1500; //actual length is 1568
//A place holder (failed NID) assembly also captures the sync (48 bits) and NID (64 bits) of the next frame, up to
//the point that the next frame's NID is decoded.
private static final int NEXT_FRAME_SYNC_NID_BIT_LENGTH = 112;
//Place holder length tolerance (bits) for identifying a missed TDU or TDULC frame. Testing against P25 recordings
//found missed TDU and TDULC place holders 1-12 bits shorter than the nominal length.
private static final int MISSED_FRAME_BIT_LENGTH_TOLERANCE = 12;
private CorrectedBinaryMessage mMessage;
private P25P1DataUnitID mDataUnitID;
private int mNac;
//Soft symbols (radians) for each assembled dibit, in received order, or NaN when not provided.
private float[] mSoftSymbols = new float[256];
//Soft symbol reliabilities for each assembled dibit, in received order, or NaN when not provided.
private float[] mReliabilities = new float[256];
private int mSoftSymbolCount = 0;
/**
@@ -96,11 +104,15 @@ public class P25P1MessageAssembler implements Listener<Dibit>
}
/**
* Forces this assembler to complete assembly of the current message. Attempts to identify
* Forces this assembler to complete assembly of the current message. Attempts to identify the message by either
* retaining the existing data unit ID when there was a valid NID decoded, or updating the place holder data unit
* ID based on the quantity of bits assembled thus far and the previous and next data unit IDs.
*
*
* ion by either retaining the existing data unit ID when there was a valid NID
* decoded, or updating the data unit ID based on the quantity of dibits assembled thus far.
* A place holder is identified as a TDU only when its length matches a single missed TDU frame. A TDU has no
* error detection, so a TDU created from a place holder of any other length, ie inter-frame padding or a missed
* frame of another type, is always valid and falsely indicates the end of a call. A place holder that matches a
* single missed TDULC frame is identified as a TDULC, which is validated by its own error correction. A place
* holder that can't be identified remains a place holder, which is discarded.
* @param previous data unit ID for the message prior to the currently assembled message
* @param next data unit ID for the message that will be assembled next
* @return number of bits that were dropped.
@@ -113,11 +125,9 @@ public class P25P1MessageAssembler implements Listener<Dibit>
{
int length = getMessage().currentSize();
if(length <= 28)
{
mDataUnitID = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
}
else
//A place holder that's shorter than the shortest (TDU) missed frame is inter-frame padding or timing slack
//ahead of the next sync, not a missed frame, so it's left as a place holder and discarded.
if(length >= getMissedFrameMinimumLength(P25P1DataUnitID.TERMINATOR_DATA_UNIT))
{
switch(next)
{
@@ -186,17 +196,55 @@ public class P25P1MessageAssembler implements Listener<Dibit>
break;
}
}
//If we're still PLACE_HOLDER, identify a single missed TDU or TDULC frame from the length
if(mDataUnitID == P25P1DataUnitID.PLACE_HOLDER)
{
if(isMissedFrame(length, P25P1DataUnitID.TERMINATOR_DATA_UNIT))
{
mDataUnitID = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
}
else if(isMissedFrame(length, P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL))
{
mDataUnitID = P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL;
}
}
}
//An unidentified place holder is discarded, so there are no dropped bits to report. The place holder bits
//are reported as sync loss.
if(mDataUnitID == P25P1DataUnitID.PLACE_HOLDER)
{
mDataUnitID = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
return 0;
}
getMessage().setSize(mDataUnitID.getMessageLength());
return getMessage().size() - getMessage().currentSize();
}
/**
* Indicates if the place holder length matches a single missed frame of the data unit type: the message and null
* bits plus the sync and NID of the next frame.
* @param length of the place holder in bits
* @param dataUnitID of the missed frame
* @return true if the length is within tolerance of the missed frame length.
*/
private static boolean isMissedFrame(int length, P25P1DataUnitID dataUnitID)
{
return length >= getMissedFrameMinimumLength(dataUnitID) &&
length <= dataUnitID.getMessageLength() + NEXT_FRAME_SYNC_NID_BIT_LENGTH;
}
/**
* Minimum place holder length for a single missed frame of the data unit type.
* @param dataUnitID of the missed frame
* @return minimum length in bits.
*/
private static int getMissedFrameMinimumLength(P25P1DataUnitID dataUnitID)
{
return dataUnitID.getMessageLength() + NEXT_FRAME_SYNC_NID_BIT_LENGTH - MISSED_FRAME_BIT_LENGTH_TOLERANCE;
}
/**
* The decoded NAC or zero if the NAC hasn't yet been fully decoded.
* @return NAC
@@ -231,6 +279,18 @@ public class P25P1MessageAssembler implements Listener<Dibit>
* @param softSymbol the dibit was decided from (radians) or NaN if unavailable.
*/
public void receive(Dibit dibit, float softSymbol)
{
receive(dibit, softSymbol, Float.NaN);
}
/**
* Primary input method for demodulated dibits/symbols, their soft symbol values and the soft symbol reliabilities
* to be appended to the message under assembly. Reliabilities weight the soft decision decoding.
* @param dibit to add to the message.
* @param softSymbol the dibit was decided from (radians) or NaN if unavailable.
* @param reliability of the soft symbol or NaN if unavailable.
*/
public void receive(Dibit dibit, float softSymbol, float reliability)
{
if(mMessage.isFull())
{
@@ -244,8 +304,10 @@ public class P25P1MessageAssembler implements Listener<Dibit>
if(mSoftSymbolCount >= mSoftSymbols.length)
{
mSoftSymbols = java.util.Arrays.copyOf(mSoftSymbols, mSoftSymbols.length * 2);
mReliabilities = java.util.Arrays.copyOf(mReliabilities, mReliabilities.length * 2);
}
mReliabilities[mSoftSymbolCount] = reliability;
mSoftSymbols[mSoftSymbolCount++] = softSymbol;
}
}
@@ -279,6 +341,35 @@ public class P25P1MessageAssembler implements Listener<Dibit>
return softSymbols;
}
/**
* Soft symbol reliabilities for the assembled message bit range.
* @param startBit of the range (must be even/dibit aligned)
* @param endBit of the range, exclusive
* @return reliabilities for the range or null if the range isn't fully assembled or any reliability is unavailable.
*/
public float[] getReliabilities(int startBit, int endBit)
{
int startDibit = startBit / 2;
int endDibit = endBit / 2;
if(startBit % 2 != 0 || endBit % 2 != 0 || startDibit < 0 || endDibit > mSoftSymbolCount || endDibit <= startDibit)
{
return null;
}
float[] reliabilities = java.util.Arrays.copyOfRange(mReliabilities, startDibit, endDibit);
for(float reliability : reliabilities)
{
if(Float.isNaN(reliability))
{
return null;
}
}
return reliabilities;
}
/**
* Indicates if the message is fully assembled meaning that the quantity of assembled dibits matches the data unit
* ID message length.

View File

@@ -59,6 +59,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
//Soft symbols (radians) for the NID dibits, or NaN when the soft symbol wasn't provided
private float[] mNIDSoftBuffer = new float[DIBIT_LENGTH_NID];
private float mCurrentSoftSymbol = Float.NaN;
private float mCurrentReliability = Float.NaN;
//NID dibit index that carries the embedded status symbol
private static final int NID_STATUS_DIBIT_INDEX = 11;
//Soft decision NID decoding: maximum hard bit disagreements (of 63) allowed for the best candidate codeword.
@@ -76,6 +77,8 @@ public class P25P1MessageFramer extends C4FMMessageFramer
//Indicates the NID under capture follows a sync detected while a message was still assembling
private boolean mPreemptiveNIDCheck = false;
//Indicates soft symbols are phase wrapped to +/- PI (ie P25 LSM) for soft decision decoding
private boolean mPhaseWrappedSoftSymbols = false;
private Listener<IMessage> mMessageListener;
private boolean mMessageAssemblyRequired = false;
private boolean mRunning = false;
@@ -99,6 +102,16 @@ public class P25P1MessageFramer extends C4FMMessageFramer
{
}
/**
* Sets the soft symbol domain for soft decision decoding.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie differentially demodulated P25 LSM)
* or false (default) for C4FM soft symbols that are not phase wrapped.
*/
public void setPhaseWrappedSoftSymbols(boolean phaseWrapped)
{
mPhaseWrappedSoftSymbols = phaseWrapped;
}
/**
* Process soft symbol and apply soft symbol sync pattern detection.
* @param softSymbol demodulated soft symbol
@@ -162,6 +175,20 @@ public class P25P1MessageFramer extends C4FMMessageFramer
return validNIDDetected;
}
/**
* Process symbol decision, the soft symbol it was decided from and the soft symbol reliability, without sync
* detection. The reliability weights the soft decision decoding.
* @param symbol that was demodulated.
* @param softSymbol equalized soft symbol in radians.
* @param reliability of the soft symbol.
*/
@Override
public boolean process(Dibit symbol, float softSymbol, float reliability)
{
mCurrentReliability = reliability;
return process(symbol, softSymbol);
}
/**
* Process hard symbol decision without sync detection.
* @param symbol that was demodulated.
@@ -171,6 +198,8 @@ public class P25P1MessageFramer extends C4FMMessageFramer
boolean validNIDDetected = false;
float softSymbol = mCurrentSoftSymbol;
mCurrentSoftSymbol = Float.NaN;
float reliability = mCurrentReliability;
mCurrentReliability = Float.NaN;
mDebugSymbolCount++;
mDibitSinceTimestampCounter++;
@@ -215,12 +244,12 @@ public class P25P1MessageFramer extends C4FMMessageFramer
//If we still have an assembler, feed it the current dibit (e.g. TSBK and PDU continuation block assembly)
if(mMessageAssembler != null)
{
mMessageAssembler.receive(symbol, softSymbol);
mMessageAssembler.receive(symbol, softSymbol, reliability);
}
}
else
{
mMessageAssembler.receive(symbol, softSymbol);
mMessageAssembler.receive(symbol, softSymbol, reliability);
}
}
//Start a message assembler after ignoring 24x Sync, 32x NID, and 1x status dibits. Trigger assembler
@@ -406,7 +435,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
{
case TRUNKING_SIGNALING_BLOCK_1:
CorrectedBinaryMessage message1 = mMessageAssembler.getMessage().getSubMessage(0, 196);
TSBKMessage tsbk1 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message1, mMessageAssembler.getSoftSymbols(0, 196), mMessageAssembler.getNAC(), getTimestamp());
TSBKMessage tsbk1 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message1, mMessageAssembler.getSoftSymbols(0, 196), mMessageAssembler.getReliabilities(0, 196), mPhaseWrappedSoftSymbols, mMessageAssembler.getNAC(), getTimestamp());
if(tsbk1 != null)
{
@@ -437,7 +466,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
break;
case TRUNKING_SIGNALING_BLOCK_2:
CorrectedBinaryMessage message2 = mMessageAssembler.getMessage().getSubMessage(196, 392);
TSBKMessage tsbk2 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message2, mMessageAssembler.getSoftSymbols(196, 392), mMessageAssembler.getNAC(), getTimestamp());
TSBKMessage tsbk2 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message2, mMessageAssembler.getSoftSymbols(196, 392), mMessageAssembler.getReliabilities(196, 392), mPhaseWrappedSoftSymbols, mMessageAssembler.getNAC(), getTimestamp());
if(tsbk2 != null)
{
@@ -466,7 +495,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
break;
case TRUNKING_SIGNALING_BLOCK_3:
CorrectedBinaryMessage message3 = mMessageAssembler.getMessage().getSubMessage(392, 588);
TSBKMessage tsbk3 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message3, mMessageAssembler.getSoftSymbols(392, 588), mMessageAssembler.getNAC(), getTimestamp());
TSBKMessage tsbk3 = TSBKMessageFactory.create(mChannelStatusProcessor.getDirection(), mMessageAssembler.getDataUnitID(), message3, mMessageAssembler.getSoftSymbols(392, 588), mMessageAssembler.getReliabilities(392, 588), mPhaseWrappedSoftSymbols, mMessageAssembler.getNAC(), getTimestamp());
broadcast(tsbk3);
adjustDibitCounterFromMessageAssembler();
@@ -511,7 +540,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
{
case PACKET_DATA_UNIT:
CorrectedBinaryMessage message = mMessageAssembler.getMessage().getSubMessage(0, 196);
PDUHeader header = PDUMessageFactory.createHeader(message, mMessageAssembler.getSoftSymbols(0, 196));
PDUHeader header = PDUMessageFactory.createHeader(message, mMessageAssembler.getSoftSymbols(0, 196), mMessageAssembler.getReliabilities(0, 196), mPhaseWrappedSoftSymbols);
if(header != null)
{
@@ -546,7 +575,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
}
else
{
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB1, mMessageAssembler.getSoftSymbols(196, 392)));
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB1, mMessageAssembler.getSoftSymbols(196, 392), mMessageAssembler.getReliabilities(196, 392), mPhaseWrappedSoftSymbols));
}
if(mPDUSequence.isComplete())
@@ -577,7 +606,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
}
else
{
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB2, mMessageAssembler.getSoftSymbols(392, 588)));
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB2, mMessageAssembler.getSoftSymbols(392, 588), mMessageAssembler.getReliabilities(392, 588), mPhaseWrappedSoftSymbols));
}
if(mPDUSequence.isComplete())
@@ -608,7 +637,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
}
else
{
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB3, mMessageAssembler.getSoftSymbols(588, 784)));
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB3, mMessageAssembler.getSoftSymbols(588, 784), mMessageAssembler.getReliabilities(588, 784), mPhaseWrappedSoftSymbols));
}
if(mPDUSequence.isComplete())
@@ -639,7 +668,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
}
else
{
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB4, mMessageAssembler.getSoftSymbols(784, 980)));
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB4, mMessageAssembler.getSoftSymbols(784, 980), mMessageAssembler.getReliabilities(784, 980), mPhaseWrappedSoftSymbols));
}
if(mPDUSequence.isComplete())
@@ -670,7 +699,7 @@ public class P25P1MessageFramer extends C4FMMessageFramer
}
else
{
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB5, mMessageAssembler.getSoftSymbols(980, 1176)));
mPDUSequence.addDataBlock(PDUMessageFactory.createUnconfirmedDataBlock(messageB5, mMessageAssembler.getSoftSymbols(980, 1176), mMessageAssembler.getReliabilities(980, 1176), mPhaseWrappedSoftSymbols));
}
adjustDibitCounterFromMessageAssembler();

View File

@@ -119,9 +119,12 @@ public class P25P1MessageProcessor implements Listener<IMessage>
//Reassemble extended source link control messages.
if(message instanceof LDU1Message ldu1)
{
LinkControlWord lcw = ldu1.getLinkControlWord();;
//The LDU1 message is valid for its voice frames, but the link control word that it carries is only used
//when it passes error correction.
LinkControlWord lcw = ldu1.getLinkControlWord();
boolean lcwValid = lcw.isValid();
if(lcw instanceof IExtendedSourceMessage esm)
if(lcwValid && lcw instanceof IExtendedSourceMessage esm)
{
if(lcw instanceof LCSourceIDExtension extension)
{
@@ -134,7 +137,7 @@ public class P25P1MessageProcessor implements Listener<IMessage>
return;
}
}
else if(lcw instanceof LCHarrisTalkerAliasBase harrisTalkerAlias)
else if(lcwValid && lcw instanceof LCHarrisTalkerAliasBase harrisTalkerAlias)
{
//Send the LCW to the harris talker alias assembler
dispatch(mHarrisTalkerAliasAssembler.process(harrisTalkerAlias, ldu1.getTimestamp()));
@@ -146,12 +149,12 @@ public class P25P1MessageProcessor implements Listener<IMessage>
dispatch(ldu1);
//Process Harris Talker GPS messages
if(lcw instanceof LCHarrisTalkerGPSBlock1 block1)
if(lcwValid && lcw instanceof LCHarrisTalkerGPSBlock1 block1)
{
mHeldHarrisGPSBlock1 = block1;
mHeldHarrisGPSBlock1Timestamp = ldu1.getTimestamp();
}
else if(lcw instanceof LCHarrisTalkerGPSBlock2 block2 && mHeldHarrisGPSBlock1 != null)
else if(lcwValid && lcw instanceof LCHarrisTalkerGPSBlock2 block2 && mHeldHarrisGPSBlock1 != null)
{
//Ensure block 1 and block 2 are within 2 seconds of each other
if(Math.abs(ldu1.getTimestamp() - mHeldHarrisGPSBlock1Timestamp) < 2000)

View File

@@ -2,7 +2,6 @@ package io.github.dsheirer.module.decode.p25.phase1;
import io.github.dsheirer.dsp.c4fm.C4FMMessageFramer;
import io.github.dsheirer.dsp.c4fm.C4FMSymbolProcessor;
import io.github.dsheirer.dsp.filter.interpolator.LinearInterpolator;
import io.github.dsheirer.dsp.symbol.Dibit;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.p25.phase1.sync.P25P1SoftSyncDetectorFactory;
@@ -205,7 +204,7 @@ public class P25P1SymbolProcessorC4FM extends C4FMSymbolProcessor
}
//If we didn't find an optimal correlation score above the threshold, return a false sync.
if(scoreCenter < SYNC_THRESHOLD_OPTIMIZED)
if(scoreCenter < getSyncThresholdOptimized())
{
return INVALID_SYNC_DETECTION;
}
@@ -265,8 +264,8 @@ public class P25P1SymbolProcessorC4FM extends C4FMSymbolProcessor
if(resampleStartIntegral >= 0)
{
symbol = patternSymbols[x];
resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[resampleStartIntegral],
mBuffer[resampleStartIntegral + 1], resampleStart - resampleStartIntegral);
resampledSoftSymbol = interpolate(mBuffer[resampleStartIntegral], mBuffer[resampleStartIntegral + 1],
resampleStart - resampleStartIntegral);
resampledSoftSymbol = resampledSoftSymbol * mSampleEqualizer.gain() + mSampleEqualizer.balance();
resampledDibit = Dibit.fromSample(resampledSoftSymbol);
bitErrorCount += patternDibits[x].getBitErrorFrom(resampledDibit);
@@ -299,10 +298,11 @@ public class P25P1SymbolProcessorC4FM extends C4FMSymbolProcessor
gainAccumulator = getGainAdjustment(mSyncDetector.getDibitLength() * Dibit.D01_PLUS_3.getIdealPhase(),
gainAccumulator);
//Recalculate the optimization score using the adjusted equalizer settings
//Recalculate the optimization score using the adjusted equalizer settings, at the same (detector and timing
//adjusted) offset where the sync symbols were resampled.
if(!mSynchronized)
{
optimizationScore = score(offset + timingCorrection, mSamplesPerSymbol,
optimizationScore = score(offset + additionalOffset + timingCorrection, mSamplesPerSymbol,
mSampleEqualizer.balance() + balanceCorrection, mSampleEqualizer.gain() + gainAccumulator);
}

View File

@@ -0,0 +1,131 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.c4fm.C4FMMessageFramer;
import io.github.dsheirer.module.decode.FeedbackDecoder;
/**
* P25 Phase 1 LSM implementation of the symbol processor. Processes the differentially demodulated sample stream
* produced by the P25P1DifferentialDemodulatorLSM, which is in the same radians per sample domain as the C4FM decoder's
* sample stream, so this processor shares the P25 C4FM sync detection, optimization and tuning parameters.
*
* Note: testing against LSM recordings without injected noise showed no meaningful difference across equalizer spans
* of 1-11 symbols, with dynamic balance tracking disabled, or with the noise gate disabled. With injected noise, a
* single symbol equalizer span performed slightly better and dynamic balance tracking performed better. Disabling the
* continuous timing tracking significantly reduced valid messages on a simulcast recording.
*
* The demodulated sample stream is phase wrapped, so this processor interpolates across the phase wrap (see
* interpolate()). Measuring the sync correction balance error and the noise detection sample differences across the
* phase wrap made no measurable difference, so those use the C4FM implementations.
*/
public class P25P1SymbolProcessorLSM extends P25P1SymbolProcessorC4FM
{
//Sync detection thresholds and the equalizer span were tuned with noise injection testing against LSM recordings at
//0-12 dB carrier to noise (50 kHz bandwidth). Relative to the C4FM values (80/100/100, 85% validation, 7 symbol
//equalizer), these increased valid messages 52% at 0 dB with 0.2% fewer valid messages without injected noise, and
//no false message detections against noise. The reference sync waveform validation threshold is the largest
//contributor since sync is frequently lost and reacquired at low SNR (see below).
private static final float SYNC_THRESHOLD_DETECTED = 70;
private static final float SYNC_THRESHOLD_OPTIMIZED = 85;
private static final float SYNC_THRESHOLD_EQUALIZED = 85;
//Reference sync waveform validation threshold for the first two sync detections after a sync loss. At low SNR,
//sync is frequently lost and reacquired, and true sync detections score 0.6 (median) at 6-9 dB carrier to noise
//(in-band), so a higher threshold rejects most reacquisitions: 0.75 rejected 17% of true syncs at 9 dB and 44% at
//6 dB. False sync detections are rejected by the optimization score threshold before validation. 0.45 increased
//valid messages 9% at 9 dB and 134% at 6 dB without false message detections against noise, while 0.3 produced a
//false NID decode against noise.
private static final double SYNC_WAVEFORM_VALIDATION_THRESHOLD = 0.45;
private static final int EQUALIZER_SYMBOL_LENGTH = 1;
//Phase wrap period of the demodulated sample stream: 2 * PI radians per symbol, in radians per sample
private float mWrapPeriod = (float)(2.0 * Math.PI);
/**
* Constructs an instance
*
* @param feedbackDecoder parent decoder
* @param messageFramer to receive symbol decisions (dibits) and sync notifications.
*/
public P25P1SymbolProcessorLSM(FeedbackDecoder feedbackDecoder, C4FMMessageFramer messageFramer)
{
super(feedbackDecoder, messageFramer);
}
@Override
public void setSamplesPerSymbol(float samplesPerSymbol)
{
mWrapPeriod = (float)(2.0 * Math.PI / samplesPerSymbol);
super.setSamplesPerSymbol(samplesPerSymbol);
}
/**
* Interpolates across the phase wrap. The differentially demodulated sample stream is wrapped to +/- PI radians
* per symbol and the outer symbols (+/- 3PI/4) are only PI/4 from the wrap, so consecutive samples near an outer
* symbol can straddle the wrap (ie +3.0 and -3.0) where linear interpolation produces a value near zero. This
* unwraps the second sample relative to the first, interpolates, and wraps the result to +/- PI radians per symbol.
*/
@Override
protected float interpolate(float sample1, float sample2, double mu)
{
float wrap = mWrapPeriod;
float delta = sample2 - sample1;
delta -= wrap * Math.round(delta / wrap);
float interpolated = (float)(sample1 + delta * mu);
return interpolated - wrap * Math.round(interpolated / wrap);
}
@Override
public int getEqualizerSymbolLength()
{
return EQUALIZER_SYMBOL_LENGTH;
}
@Override
public float getSyncThresholdDetected()
{
return SYNC_THRESHOLD_DETECTED;
}
@Override
public float getSyncThresholdOptimized()
{
return SYNC_THRESHOLD_OPTIMIZED;
}
@Override
public float getSyncThresholdEqualized()
{
return SYNC_THRESHOLD_EQUALIZED;
}
@Override
public double getSyncWaveformValidationThreshold()
{
return SYNC_WAVEFORM_VALIDATION_THRESHOLD;
}
/**
* Indicates if the symbol processor is synchronized to the signal (ie sync detected within the max message length)
*/
public boolean isSynchronized()
{
return mSynchronized;
}
}

View File

@@ -88,6 +88,17 @@ public class HDUMessage extends P25P1Message
return P25P1DataUnitID.HEADER_DATA_UNIT;
}
/**
* Indicates if the header data passed Reed-Solomon error detection and correction. Decodes the header data when
* it's not already decoded, since the decoding sets the message validity.
*/
@Override
public boolean isValid()
{
getHeaderData();
return super.isValid();
}
public HeaderData getHeaderData()
{
if(mHeaderData == null)

View File

@@ -108,6 +108,20 @@ public class PDUMessageFactory
* @return header or null.
*/
public static PDUHeader createHeader(CorrectedBinaryMessage message, float[] softSymbols)
{
return createHeader(message, softSymbols, null, false);
}
/**
* Creates a PDU header message using soft decision Viterbi decoding when soft symbols are provided.
* @param message containing an interleaved, viterbi encoded message
* @param softSymbols for the 98 interleaved dibits of the header, or null.
* @param reliabilities for the 98 interleaved soft symbols to weight the soft decision decoding, or null.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie P25 LSM).
* @return header or null.
*/
public static PDUHeader createHeader(CorrectedBinaryMessage message, float[] softSymbols, float[] reliabilities,
boolean phaseWrapped)
{
if(softSymbols == null)
{
@@ -115,7 +129,7 @@ public class PDUMessageFactory
}
CorrectedBinaryMessage decoded = TSBKMessageFactory.deinterleaveViterbiAndCrc(message.getSubMessage(0, 196),
softSymbols);
softSymbols, reliabilities, phaseWrapped);
if(decoded != null)
{
@@ -183,13 +197,29 @@ public class PDUMessageFactory
* @param softSymbols for the 98 interleaved dibits of the block, or null.
*/
public static DataBlock createUnconfirmedDataBlock(CorrectedBinaryMessage interleaved, float[] softSymbols)
{
return createUnconfirmedDataBlock(interleaved, softSymbols, null, false);
}
/**
* Creates an unconfirmed data block using soft decision Viterbi decoding when soft symbols are provided.
* @param interleaved data block
* @param softSymbols for the 98 interleaved dibits of the block, or null.
* @param reliabilities for the 98 interleaved soft symbols to weight the soft decision decoding, or null.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie P25 LSM).
*/
public static DataBlock createUnconfirmedDataBlock(CorrectedBinaryMessage interleaved, float[] softSymbols,
float[] reliabilities, boolean phaseWrapped)
{
if(softSymbols == null || softSymbols.length != P25P1Interleave.DATA_DEINTERLEAVE.length / 2)
{
return createUnconfirmedDataBlock(interleaved);
}
return new UnconfirmedDataBlock(P25P1Interleave.deinterleaveDataSoftSymbols(softSymbols));
float[] deinterleavedReliabilities = reliabilities != null && reliabilities.length == softSymbols.length ?
P25P1Interleave.deinterleaveDataSoftSymbols(reliabilities) : null;
return new UnconfirmedDataBlock(P25P1Interleave.deinterleaveDataSoftSymbols(softSymbols),
deinterleavedReliabilities, phaseWrapped);
}
public static DataBlock createUnconfirmedDataBlock(CorrectedBinaryMessage interleaved)

View File

@@ -47,7 +47,20 @@ public class UnconfirmedDataBlock extends DataBlock
*/
public UnconfirmedDataBlock(float[] deinterleavedSoftSymbols)
{
mDecodedMessage = VITERBI_HALF_RATE_DECODER.decodeSoft(deinterleavedSoftSymbols);
this(deinterleavedSoftSymbols, null, false);
}
/**
* Constructs an unconfirmed data block using soft decision Viterbi decoding.
* @param deinterleavedSoftSymbols 98 deinterleaved soft symbols for the 196-bit data block.
* @param deinterleavedReliabilities for each deinterleaved soft symbol to weight the soft decision decoding, or null.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie P25 LSM).
*/
public UnconfirmedDataBlock(float[] deinterleavedSoftSymbols, float[] deinterleavedReliabilities,
boolean phaseWrapped)
{
mDecodedMessage = VITERBI_HALF_RATE_DECODER.decodeSoft(deinterleavedSoftSymbols, deinterleavedReliabilities,
phaseWrapped);
}
/**

View File

@@ -74,6 +74,16 @@ public class TDULCMessage extends P25P1Message implements IFrequencyBandReceiver
return P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL;
}
/**
* Indicates if the link control word passed Reed-Solomon error detection and correction. Decodes the link control
* word when it's not already decoded.
*/
@Override
public boolean isValid()
{
return getLinkControlWord().isValid();
}
public LinkControlWord getLinkControlWord()
{
if(mLinkControlWord == null)

View File

@@ -157,13 +157,32 @@ public class TSBKMessageFactory
* 2 bit errors is invalid.
*/
public static CorrectedBinaryMessage deinterleaveViterbiAndCrc(CorrectedBinaryMessage raw, float[] softSymbols)
{
return deinterleaveViterbiAndCrc(raw, softSymbols, null, false);
}
/**
* Performs deinterleave, Viterbi decoding, and applies the CRC-CCITT error detection/correction. Uses soft
* decision Viterbi decoding when the soft symbols are provided, otherwise hard decision decoding.
* @param raw interleaved message
* @param softSymbols for the 98 interleaved dibits of the raw message, or null.
* @param reliabilities for the 98 interleaved soft symbols to weight the soft decision decoding, or null.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie P25 LSM).
* @return null or the decoded message with CRC results set in the message where 0 or 1 bit errors is valid and
* 2 bit errors is invalid.
*/
public static CorrectedBinaryMessage deinterleaveViterbiAndCrc(CorrectedBinaryMessage raw, float[] softSymbols,
float[] reliabilities, boolean phaseWrapped)
{
CorrectedBinaryMessage message;
if(softSymbols != null && softSymbols.length == P25P1Interleave.DATA_DEINTERLEAVE.length / 2)
{
//Soft decision decode 1/2 rate trellis encoded block from the deinterleaved soft symbols
message = VITERBI_HALF_RATE_DECODER.decodeSoft(P25P1Interleave.deinterleaveDataSoftSymbols(softSymbols));
float[] deinterleavedReliabilities = reliabilities != null && reliabilities.length == softSymbols.length ?
P25P1Interleave.deinterleaveDataSoftSymbols(reliabilities) : null;
message = VITERBI_HALF_RATE_DECODER.decodeSoft(P25P1Interleave.deinterleaveDataSoftSymbols(softSymbols),
deinterleavedReliabilities, phaseWrapped);
}
else
{
@@ -205,7 +224,21 @@ public class TSBKMessageFactory
CorrectedBinaryMessage correctedBinaryMessage, float[] softSymbols, int nac,
long timestamp)
{
CorrectedBinaryMessage message = deinterleaveViterbiAndCrc(correctedBinaryMessage, softSymbols);
return create(direction, dataUnitID, correctedBinaryMessage, softSymbols, null, false, nac, timestamp);
}
/**
* Creates a TSBK message, using soft decision Viterbi decoding when soft symbols are provided.
* @param softSymbols for the 98 interleaved dibits of the message, or null for hard decision decoding.
* @param reliabilities for the 98 interleaved soft symbols to weight the soft decision decoding, or null.
* @param phaseWrapped true when the soft symbols are phase wrapped to +/- PI (ie P25 LSM).
*/
public static TSBKMessage create(Direction direction, P25P1DataUnitID dataUnitID,
CorrectedBinaryMessage correctedBinaryMessage, float[] softSymbols,
float[] reliabilities, boolean phaseWrapped, int nac, long timestamp)
{
CorrectedBinaryMessage message = deinterleaveViterbiAndCrc(correctedBinaryMessage, softSymbols, reliabilities,
phaseWrapped);
if(message == null)
{

View File

@@ -23,8 +23,6 @@ import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.channel.state.MultiChannelState;
import io.github.dsheirer.controller.channel.Channel;
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.gain.complex.ComplexGainFactory;
import io.github.dsheirer.dsp.gain.complex.IComplexGainControl;
@@ -51,9 +49,7 @@ import io.github.dsheirer.source.wave.ComplexWaveSource;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -69,7 +65,6 @@ public class P25P2DecoderHDQPSK extends P25P2Decoder implements IdentifierUpdate
protected CostasLoop mCostasLoop;
protected P25P2MessageFramer mMessageFramer;
protected IComplexGainControl mAGC = ComplexGainFactory.getComplexGainControl();
private Map<Double,float[]> mBasebandFilters = new HashMap<>();
protected IRealFilter mIBasebandFilter;
protected IRealFilter mQBasebandFilter;
private DecodeConfigP25Phase2 mDecodeConfigP25Phase2;
@@ -107,8 +102,10 @@ public class P25P2DecoderHDQPSK extends P25P2Decoder implements IdentifierUpdate
{
super.setSampleRate(sampleRate);
mIBasebandFilter = FilterFactory.getRealFilter(getBasebandFilter());
mQBasebandFilter = FilterFactory.getRealFilter(getBasebandFilter());
//Note: the baseband filter is designed for a 50 kHz sample rate, independent of the current sample rate.
float[] basebandTaps = FilterFactory.getRemezLowPass(50000.0, 6500, 0.005, 7200, 0.01);
mIBasebandFilter = FilterFactory.getRealFilter(basebandTaps);
mQBasebandFilter = FilterFactory.getRealFilter(basebandTaps);
mCostasLoop = new CostasLoop(getSampleRate(), getSymbolRate());
mCostasLoop.setPLLBandwidth(PLLBandwidth.BW_300);
@@ -152,48 +149,6 @@ public class P25P2DecoderHDQPSK extends P25P2Decoder implements IdentifierUpdate
mQPSKDemodulator.receive(amplified);
}
/**
* Constructs a baseband filter for this decoder using the current sample rate
*/
private float[] getBasebandFilter()
{
//Attempt to reuse a cached (ie already-designed) filter if available
float[] filter = mBasebandFilters.get(getSampleRate());
if(filter == null)
{
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
.sampleRate(50000.0)
.passBandCutoff(6500)
.passBandAmplitude(1.0)
.passBandRipple(0.005)
.stopBandAmplitude(0.0)
.stopBandStart(7200)
.stopBandRipple(0.01)
.build();
try
{
filter = FilterFactory.getTaps(specification);
}
catch(FilterDesignException fde)
{
mLog.error("Couldn't design low pass baseband filter for sample rate: " + getSampleRate());
}
if(filter != null)
{
mBasebandFilters.put(getSampleRate(), filter);
}
else
{
throw new IllegalStateException("Couldn't design a C4FM baseband filter for sample rate: " + getSampleRate());
}
}
return filter;
}
@Override
protected void process(SourceEvent sourceEvent)
{

View File

@@ -260,7 +260,7 @@ public class MixerReader<T>
{
while(bytesRead < buffer.length)
{
bytesRead += mTargetDataLine.read(buffer, bytesRead, buffer.length);
bytesRead += mTargetDataLine.read(buffer, bytesRead, buffer.length - bytesRead);
}
}
catch(ArrayIndexOutOfBoundsException aioobe)

View File

@@ -42,6 +42,7 @@ public class ChannelFrequencyErrorManager implements ISourceEventListener, Liste
private final TunerChannelSource mTunerChannelSource;
private final TunerFrequencyErrorManager mParent;
private ScheduledFuture<?> mScheduledFuture;
//Guarded by this instance: updated by this manager's timer and by the tuner's PPM change notifications
private long mAppliedFrequencyCorrection = 0;
private long mAverageRequestedCorrection = 0;
private boolean mCurrentForTunerProcessing = false;
@@ -73,7 +74,7 @@ public class ChannelFrequencyErrorManager implements ISourceEventListener, Liste
* Snapshot of the current applied frequency correction for the channel.
* @return total frequency error in Hertz.
*/
public long getFrequencyError()
public synchronized long getFrequencyError()
{
return mAppliedFrequencyCorrection;
}
@@ -81,12 +82,22 @@ public class ChannelFrequencyErrorManager implements ISourceEventListener, Liste
/**
* Receive a notification from the parent tuner is updating the PPM and frequency correction change value.
*
* Note: on receiving this change notification, the tuner manager will expose the current PPM and correction values.
* The tuner PPM change shifts every channel by the change: a positive PPM change lowers the tuned frequency, which
* raises each channel's signal frequency by the change, the same as a positive channel frequency correction. The
* tuner absorbs the change from the average of the channel corrections, so remove the change from this channel's
* applied correction to keep the channel's net correction (and the decoder's view of the signal) unchanged.
* Otherwise, each PPM change appears to the decoder as a sudden carrier offset step that the channel correction
* removes over the following correction intervals, while the tuner continues to absorb the stale channel
* correction.
*
* Note: invoked by the tuner's frequency error manager while holding the tuner controller lock, so this method
* must not call back into the tuner.
* @param change in Hertz resulting from the tuner's PPM update.
*/
public void receive(long change)
public synchronized void receive(long change)
{
//The TunerFrequencyErrorManager notifies this channel of the correction applied at the tuner
mAppliedFrequencyCorrection -= change;
mTunerChannelSource.setFrequencyCorrection(mAppliedFrequencyCorrection);
}
/**
@@ -133,16 +144,25 @@ public class ChannelFrequencyErrorManager implements ISourceEventListener, Liste
if(mCurrentForChannelProcessing)
{
long partialCorrection = Math.clamp(mAverageRequestedCorrection, -MAXIMUM_CHANNEL_ERROR_CORRECTION_PER_INTERVAL, MAXIMUM_CHANNEL_ERROR_CORRECTION_PER_INTERVAL);
long appliedFrequencyCorrection;
if(partialCorrection != 0)
//receive(long change) holds the tuner controller lock while it locks this instance, so the status
//notification below, which calls into the tuner, is created outside of this block to avoid a lock order
//inversion.
synchronized(this)
{
mAppliedFrequencyCorrection += (long)(partialCorrection * ERROR_PROCESSING_GAIN);
mTunerChannelSource.setFrequencyCorrection(mAppliedFrequencyCorrection);
mRequestedCorrectAveragingBuffer.reset();
if(partialCorrection != 0)
{
mAppliedFrequencyCorrection += (long)(partialCorrection * ERROR_PROCESSING_GAIN);
mTunerChannelSource.setFrequencyCorrection(mAppliedFrequencyCorrection);
mRequestedCorrectAveragingBuffer.reset();
}
appliedFrequencyCorrection = mAppliedFrequencyCorrection;
}
ChannelFrequencyCorrectionStatusNotification notification = ChannelFrequencyCorrectionStatusNotification
.create(mTunerChannelSource, mAverageRequestedCorrection, mAppliedFrequencyCorrection, mParent.getTunerPPM(),
.create(mTunerChannelSource, mAverageRequestedCorrection, appliedFrequencyCorrection, mParent.getTunerPPM(),
mParent.getTunerFrequencyCorrection(), mParent.isEnabled());
mTunerChannelSource.broadcastConsumerSourceEvent(notification);
mAverageRequestedCorrection = 0;
@@ -152,7 +172,7 @@ public class ChannelFrequencyErrorManager implements ISourceEventListener, Liste
else
{
ChannelFrequencyCorrectionStatusNotification notification = ChannelFrequencyCorrectionStatusNotification
.create(mTunerChannelSource, 0, mAppliedFrequencyCorrection, mParent.getTunerPPM(),
.create(mTunerChannelSource, 0, getFrequencyError(), mParent.getTunerPPM(),
mParent.getTunerFrequencyCorrection(), mParent.isEnabled());
mTunerChannelSource.broadcastConsumerSourceEvent(notification);
}

View File

@@ -132,6 +132,13 @@ public class TunerFrequencyErrorManager implements ISourceEventProcessor
}
}
//Skip the update when no channel has reported a measurement since the last update. A divide by zero
//exception would cancel all subsequent executions of this scheduled task.
if(count == 0)
{
return;
}
requestedChangeHz /= count;
mTunerController.setMeasuredFrequencyError((int)requestedChangeHz);

View File

@@ -0,0 +1,109 @@
/*
* *****************************************************************************
* 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.buffer;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the float native buffer.
*/
public class FloatNativeBufferTest
{
private static final int SAMPLE_COUNT = 5000;
/**
* Creates interleaved complex samples where each value is unique and non-zero.
*/
private static float[] getSamples()
{
float[] samples = new float[SAMPLE_COUNT * 2];
for(int x = 0; x < samples.length; x++)
{
samples[x] = x + 1;
}
return samples;
}
/**
* Test: the complex samples iterator returns every sample exactly once, with a final chunk that holds the remaining
* samples rather than padding them to the full chunk length with zero valued samples.
*/
@Test
void testComplexSamplesIterator()
{
float[] samples = getSamples();
List<Integer> lengths = new ArrayList<>();
int pointer = 0;
Iterator<ComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iterator();
while(iterator.hasNext())
{
ComplexSamples chunk = iterator.next();
lengths.add(chunk.i().length);
for(int x = 0; x < chunk.i().length; x++)
{
assertEquals(samples[pointer++], chunk.i()[x], "Inphase sample " + (pointer / 2));
assertEquals(samples[pointer++], chunk.q()[x], "Quadrature sample " + (pointer / 2));
}
}
assertEquals(samples.length, pointer, "Sample count");
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
}
/**
* Test: the interleaved complex samples iterator returns every sample exactly once, without zero padding.
*/
@Test
void testInterleavedComplexSamplesIterator()
{
float[] samples = getSamples();
List<Integer> lengths = new ArrayList<>();
int pointer = 0;
Iterator<InterleavedComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iteratorInterleaved();
while(iterator.hasNext())
{
float[] chunk = iterator.next().samples();
lengths.add(chunk.length / 2);
for(float sample : chunk)
{
assertEquals(samples[pointer], sample, "Sample " + pointer);
pointer++;
}
}
assertEquals(samples.length, pointer, "Sample count");
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
}
}

View File

@@ -0,0 +1,95 @@
/*
* *****************************************************************************
* 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.fm;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the FM discriminator soft symbol reliability.
*/
public class FMDiscriminatorReliabilityTest
{
private static final int BUFFER_LENGTH = 256;
/**
* Test: inverse power filter taps are the squared differences of the post-discriminator filter taps.
*/
@Test
void testInversePowerFilterTaps()
{
float[] taps = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.25f, 0.5f, 0.25f});
assertArrayEquals(new float[]{0.0625f, 0.0625f, 0.0625f, 0.0625f}, taps, 1e-7f, "Taps");
//An integrate and dump (flat) filter depends only on the samples at either end of the filter span
float[] flat = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.2f, 0.2f, 0.2f, 0.2f, 0.2f});
assertArrayEquals(new float[]{0.04f, 0f, 0f, 0f, 0f, 0.04f}, flat, 1e-7f, "Flat filter taps");
}
/**
* Test: each reliability is 1 / sum(g[k]^2 / |x[n - k]|^2), including across the sample buffer boundary.
*/
@Test
void testReliabilities()
{
float[] filter = {0.1f, 0.2f, 0.4f, 0.2f, 0.1f};
float[] g2 = FMDiscriminatorReliability.getInversePowerFilterTaps(filter);
FMDiscriminatorReliability reliability = new FMDiscriminatorReliability(filter);
float[] amplitudes = new float[2 * BUFFER_LENGTH];
for(int x = 0; x < amplitudes.length; x++)
{
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
}
float[] reliabilities = new float[amplitudes.length];
for(int buffer = 0; buffer < 2; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
//Rotate the phase to show that the reliability depends only on the sample power
double phase = x * 0.5;
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
i[x] = (float)(amplitude * Math.cos(phase));
q[x] = (float)(amplitude * Math.sin(phase));
}
System.arraycopy(reliability.calculate(i, q), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
}
for(int x = g2.length; x < amplitudes.length; x++)
{
double sum = 0.0;
for(int k = 0; k < g2.length; k++)
{
sum += g2[k] / (amplitudes[x - k] * amplitudes[x - k]);
}
assertEquals(1.0 / sum, reliabilities[x], 1e-4 / sum, "Reliability at " + x);
}
}
}

View File

@@ -0,0 +1,238 @@
/*
* *****************************************************************************
* 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.edac.trellis;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* JUnit tests for the P25 1/2 rate trellis soft decision Viterbi decoder.
*/
public class ViterbiDecoder_1_2_P25Test
{
private static final int INPUT_DIBITS = 48;
//Faded symbol spacing for the reliability weighting test (ie one third of the symbols are faded)
private static final int SPACING = 3;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Encodes the input dibit values (plus a flushing zero) into 98 ideal soft symbols.
*/
private static float[] encode(int[] inputs)
{
float[] symbols = new float[(inputs.length + 1) * 2];
int state = 0;
for(int step = 0; step <= inputs.length; step++)
{
int input = step < inputs.length ? inputs[step] : 0;
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
symbols[2 * step] = DIBITS[output >> 2].getIdealPhase();
symbols[2 * step + 1] = DIBITS[output & 3].getIdealPhase();
state = input;
}
return symbols;
}
/**
* Counts the decoded message bits that differ from the input dibit values.
*/
private static int countBitErrors(int[] inputs, CorrectedBinaryMessage decoded)
{
int errors = 0;
for(int step = 0; step < inputs.length; step++)
{
if(decoded.get(2 * step) != ((inputs[step] & 2) == 2))
{
errors++;
}
if(decoded.get(2 * step + 1) != ((inputs[step] & 1) == 1))
{
errors++;
}
}
return errors;
}
private static int[] randomInputs(Random random)
{
int[] inputs = new int[INPUT_DIBITS];
for(int x = 0; x < inputs.length; x++)
{
inputs[x] = random.nextInt(4);
}
return inputs;
}
/**
* Test: ideal soft symbols decode without error in both the linear (C4FM) and phase wrapped (LSM) modes.
*/
@Test
void testIdealSymbols()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(1);
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols, true)), "Phase wrapped");
}
}
/**
* Test: phase wrapped decoding measures the soft symbol error around the circle, so outer symbols that noise
* pushed across the +/- PI phase wrap are decoded correctly.
*/
@Test
void testOuterSymbolsAcrossPhaseWrap()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(2);
int linearErrors = 0;
int wrappedErrors = 0;
for(int trial = 0; trial < 200; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
int outerCount = 0;
for(int x = 0; x < symbols.length; x++)
{
//Push every third outer (+/- 3PI/4) symbol 0.85 radians toward and across the phase wrap
if(Math.abs(symbols[x]) > Math.PI / 2 && (outerCount++ % 3) == 0)
{
double pushed = symbols[x] + Math.signum(symbols[x]) * 0.85;
symbols[x] = (float)(pushed - 2.0 * Math.PI * Math.round(pushed / (2.0 * Math.PI)));
}
}
linearErrors += countBitErrors(inputs, decoder.decodeSoft(symbols));
wrappedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
}
assertEquals(0, wrappedErrors, "Phase wrapped bit errors");
assertTrue(wrappedErrors <= linearErrors, "Phase wrapped decoding no worse than linear decoding");
}
/**
* Test: reliability weighting decodes corrupted symbols that have low reliability that unweighted soft decision
* decoding fails to decode. The corrupted symbols are spaced across the block, the way the deinterleaver spreads
* the symbols of a transmitted envelope fade.
*/
@Test
void testReliabilityWeightedFade()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(4);
int unweightedErrors = 0;
int weightedErrors = 0;
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
float[] reliabilities = new float[symbols.length];
java.util.Arrays.fill(reliabilities, 1.0f);
//Replace every SPACING symbol with a random phase (ie a deep fade) and mark it with low reliability
for(int x = random.nextInt(SPACING); x < symbols.length; x += SPACING)
{
symbols[x] = (float)((random.nextDouble() * 2.0 - 1.0) * Math.PI);
reliabilities[x] = 0.01f;
}
unweightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
weightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, reliabilities, true));
}
assertEquals(0, weightedErrors, "Reliability weighted bit errors");
assertTrue(unweightedErrors > 0, "Unweighted decoding fails to decode the faded symbols");
}
/**
* Test: unavailable or unusable reliabilities (null, NaN or all zero) decode the same as unweighted decoding.
*/
@Test
void testUnusableReliabilities()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(5);
for(int trial = 0; trial < 20; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
for(int x = 0; x < symbols.length; x++)
{
symbols[x] += (float)(random.nextGaussian() * 0.5);
}
CorrectedBinaryMessage unweighted = decoder.decodeSoft(symbols, true);
float[] nan = new float[symbols.length];
java.util.Arrays.fill(nan, 1.0f);
nan[7] = Float.NaN;
assertEquals(unweighted, decoder.decodeSoft(symbols, null, true), "Null reliabilities");
assertEquals(unweighted, decoder.decodeSoft(symbols, nan, true), "NaN reliability");
assertEquals(unweighted, decoder.decodeSoft(symbols, new float[symbols.length], true), "Zero reliabilities");
}
}
/**
* Test: soft symbols that are not phase wrapped (C4FM) with outer symbols beyond +/- PI decode correctly with
* linear error measurement.
*/
@Test
void testUnwrappedOuterSymbolsBeyondPi()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(3);
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
for(int x = 0; x < symbols.length; x++)
{
if(Math.abs(symbols[x]) > Math.PI / 2)
{
symbols[x] += (float)(Math.signum(symbols[x]) * 0.85);
}
}
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the NAC tracker.
*/
public class NACTrackerTest
{
/**
* Test: the dominant NAC needs the minimum number of observations.
*/
@Test
void testDominantNAC()
{
NACTracker tracker = new NACTracker();
assertEquals(0, tracker.getTrackedNAC(), "Empty");
tracker.track(0x293);
tracker.track(0x293);
tracker.track(0x111);
assertEquals(0, tracker.getTrackedNAC(), "Below the minimum observations");
tracker.track(0x293);
assertEquals(0x293, tracker.getTrackedNAC(), "Dominant");
}
/**
* Test: when a fourth NAC is observed, the least recently observed NAC is evicted, independent of the system clock.
* All of the observations occur within the same millisecond, as they do when a recording decodes faster than real
* time.
*/
@Test
void testLeastRecentlyObservedEvicted()
{
for(int repeat = 0; repeat < 100; repeat++)
{
NACTracker tracker = new NACTracker();
//0x293 is the most observed, but the least recently observed when 0x444 arrives
for(int x = 0; x < 5; x++)
{
tracker.track(0x293);
}
for(int x = 0; x < 4; x++)
{
tracker.track(0x111);
}
tracker.track(0x222);
tracker.track(0x444);
assertEquals(0x111, tracker.getTrackedNAC(), "Least recently observed NAC evicted, repeat " + repeat);
}
}
}

View File

@@ -0,0 +1,409 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.symbol.Dibit;
import io.github.dsheirer.edac.CRCP25;
import io.github.dsheirer.edac.bch.BCH_63_16_23_P25;
import io.github.dsheirer.edac.trellis.P25_1_2_Node;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.TSBKMessage;
import io.github.dsheirer.sample.complex.ComplexSamples;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* End-to-end JUnit tests for the P25 Phase 1 LSM decoder: a synthetic LSM (CQPSK) control channel carrying single
* block TSBK frames is generated and decoded, and each valid decoded TSBK is checked against the transmitted TSBKs.
*/
public class P25P1DecoderLSMTest
{
private static final double SAMPLE_RATE = 48000.0;
private static final double SYMBOL_RATE = 4800.0;
private static final int FRAME_COUNT = 150;
private static final int NAC = 0x293;
//Single block TSBK frame: 180 dibits (360 bits) including a status dibit every 36th dibit and null padding
private static final int FRAME_DIBITS = 180;
private static final int TSBK_DUID = 7;
private static final int OPCODE_RFSS_STATUS_BROADCAST = 58;
//Raised cosine transmit pulse
private static final double ROLLOFF = 0.2;
private static final int PULSE_SPAN_SYMBOLS = 8;
private static final int BUFFER_SIZE = 2048;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Test: a clean signal decodes every TSBK frame, with no invalid messages.
*/
@Test
void testCleanSignal()
{
Result result = decode(0.0, 0.0, Double.NaN, 1, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages");
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
}
/**
* Test: a carrier offset within the +/- 600 Hz fourth power offset estimator range and a 100 ppm symbol rate error
* decode every TSBK frame.
*/
@Test
void testCarrierOffsetAndSymbolRateError()
{
Result result = decode(300.0, 100.0, Double.NaN, 2, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages");
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
}
/**
* Test: carrier offsets beyond the fourth power offset estimator range are resolved by the offset ambiguity search,
* after which every TSBK frame decodes. The search steps toward the adjacent offset range nearest to the estimate,
* so offsets on either side acquire after one search dwell period (~11 frames).
*/
@Test
void testOffsetAmbiguitySearch()
{
for(double carrierOffset : new double[]{700.0, -700.0, 900.0, -900.0})
{
Result result = decode(carrierOffset, -100.0, Double.NaN, 3, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages at " + carrierOffset + " Hz");
assertTrue(result.first() <= 16, "Acquisition at " + carrierOffset + " Hz: " + result.first() + " frames");
assertEquals(0, result.missed(), "Missed frames after acquisition at " + carrierOffset + " Hz");
}
}
/**
* Test: acquisition of offsets near the edge of the fourth power offset estimator range is fast and independent of
* the sample buffer size. The estimator previously updated once per buffer, so a -550 Hz offset took 36 frames to
* acquire with 2048 sample buffers and 55 frames with 8192 sample buffers.
*/
@Test
void testAcquisitionIndependentOfBufferSize()
{
for(int bufferSize : new int[]{512, 2048, 8192})
{
for(double carrierOffset : new double[]{550.0, -550.0})
{
Result result = decode(carrierOffset, 50.0, Double.NaN, 5, bufferSize);
String condition = carrierOffset + " Hz with " + bufferSize + " sample buffers";
assertEquals(0, result.invalid(), "Invalid messages at " + condition);
assertTrue(result.first() <= 5, "Acquisition at " + condition + ": " + result.first() + " frames");
assertEquals(0, result.missed(), "Missed frames after acquisition at " + condition);
}
}
}
/**
* Test: with additive white gaussian noise at 12 dB in-band (+/- 3 kHz) carrier to noise, nearly all TSBK frames
* decode and every valid TSBK matches a transmitted TSBK.
*/
@Test
void testNoise()
{
Result result = decode(200.0, 50.0, 12.0, 4, BUFFER_SIZE);
assertTrue(result.valid() >= FRAME_COUNT - 5, "Valid messages: " + result.valid());
}
/**
* Generates and decodes the synthetic signal.
* @param carrierOffset in Hertz
* @param symbolRateError in parts per million
* @param carrierToNoise in dB, in-band (+/- 3 kHz), or NaN for no noise
* @param seed for the random TSBK payloads and noise
* @param bufferSize of the sample buffers delivered to the decoder (even)
* @return decoded message counts, the index of the first decoded TSBK frame (the frame count when
* none decode) and the count of TSBK frames that
* didn't decode after the first decoded TSBK frame.
*/
private static Result decode(double carrierOffset, double symbolRateError, double carrierToNoise, long seed,
int bufferSize)
{
Random random = new Random(seed);
List<BitSet> tsbks = new ArrayList<>();
Set<BitSet> transmitted = new HashSet<>();
List<Dibit> dibits = new ArrayList<>();
for(int frame = 0; frame < FRAME_COUNT; frame++)
{
BitSet tsbk = getTSBK(random);
tsbks.add(tsbk);
transmitted.add(tsbk);
dibits.addAll(getFrame(tsbk));
}
//Trailing frame that isn't checked: the decoder dispatches a message after its trailing symbols are processed
dibits.addAll(getFrame(getTSBK(random)));
float[][] samples = modulate(dibits, carrierOffset, symbolRateError, carrierToNoise, random);
List<TSBKMessage> received = new ArrayList<>();
P25P1DecoderLSM decoder = new P25P1DecoderLSM();
decoder.setMessageListener((IMessage message) -> {
if(message instanceof TSBKMessage tsbk)
{
received.add(tsbk);
}
});
decoder.start();
decoder.setSampleRate(SAMPLE_RATE);
for(int offset = 0; offset < samples[0].length; offset += bufferSize)
{
int length = Math.min(bufferSize, samples[0].length - offset);
float[] i = new float[length];
float[] q = new float[length];
System.arraycopy(samples[0], offset, i, 0, length);
System.arraycopy(samples[1], offset, q, 0, length);
decoder.receive(new ComplexSamples(i, q, 0L));
}
decoder.stop();
int valid = 0;
int invalid = 0;
Set<BitSet> decoded = new HashSet<>();
for(TSBKMessage tsbk : received)
{
if(tsbk.isValid())
{
BitSet bits = new BitSet();
for(int x = 0; x < 80; x++)
{
bits.set(x, tsbk.getMessage().get(x));
}
//A valid TSBK that doesn't match a transmitted TSBK is a CRC false positive
assertTrue(transmitted.contains(bits), "Decoded TSBK was not transmitted");
assertTrue(decoded.add(bits), "Decoded TSBK was duplicated");
valid++;
}
else
{
invalid++;
}
}
int first = tsbks.size();
int missed = 0;
for(int x = 0; x < tsbks.size(); x++)
{
if(decoded.contains(tsbks.get(x)))
{
first = Math.min(first, x);
}
else if(first < x)
{
missed++;
}
}
return new Result(valid, invalid, first, missed);
}
/**
* Creates a random single block (last block) TSBK, 80 message bits.
*/
private static BitSet getTSBK(Random random)
{
BitSet tsbk = new BitSet();
tsbk.set(0); //Last block
setValue(tsbk, 2, 6, OPCODE_RFSS_STATUS_BROADCAST);
for(int x = 16; x < 80; x++)
{
tsbk.set(x, random.nextBoolean());
}
return tsbk;
}
/**
* Creates the frame dibits for the TSBK: sync, NID, interleaved trellis encoded TSBK and null padding, with a
* status dibit at every 36th dibit.
*/
private static List<Dibit> getFrame(BitSet tsbk)
{
List<Dibit> data = new ArrayList<>();
addBits(data, 0x5575F5FF77FFL, 48);
addBits(data, BCH_63_16_23_P25.encode(NAC, TSBK_DUID) << 1, 64); //64th (parity) bit is zero
data.addAll(encodeTSBK(tsbk));
List<Dibit> frame = new ArrayList<>();
int pointer = 0;
for(int x = 1; x <= FRAME_DIBITS; x++)
{
if(x % 36 == 0)
{
frame.add(Dibit.D01_PLUS_3); //Outbound (repeater) status
}
else
{
frame.add(pointer < data.size() ? data.get(pointer++) : Dibit.D00_PLUS_1);
}
}
return frame;
}
/**
* Adds the CRC-CCITT checksum, 1/2 rate trellis encodes and interleaves the TSBK into 98 dibits.
*/
private static List<Dibit> encodeTSBK(BitSet tsbk)
{
BitSet message = (BitSet)tsbk.clone();
int checksum = 0xFFFF;
for(int x = message.nextSetBit(0); x >= 0 && x < 80; x = message.nextSetBit(x + 1))
{
checksum ^= CRCP25.CCITT_80_CHECKSUMS[x];
}
setValue(message, 80, 16, checksum);
//48 input dibits plus a flushing zero input produce 98 encoded dibits
int[] encoded = new int[98];
int state = 0;
for(int step = 0; step < 49; step++)
{
int input = step < 48 ? (message.get(2 * step) ? 2 : 0) + (message.get(2 * step + 1) ? 1 : 0) : 0;
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
encoded[2 * step] = output >> 2;
encoded[2 * step + 1] = output & 3;
state = input;
}
List<Dibit> interleaved = new ArrayList<>();
for(int dibit = 0; dibit < encoded.length; dibit++)
{
interleaved.add(DIBITS[encoded[P25P1Interleave.DATA_DEINTERLEAVE[2 * dibit] / 2]]);
}
return interleaved;
}
/**
* Modulates the dibits as LSM: differential QPSK with raised cosine pulse shaping.
* @return i and q sample arrays
*/
private static float[][] modulate(List<Dibit> dibits, double carrierOffset, double symbolRateError,
double carrierToNoise, Random random)
{
double[] symbolI = new double[dibits.size()];
double[] symbolQ = new double[dibits.size()];
double phase = 0.0;
for(int x = 0; x < dibits.size(); x++)
{
phase += dibits.get(x).getIdealPhase();
symbolI[x] = Math.cos(phase);
symbolQ[x] = Math.sin(phase);
}
double samplesPerSymbol = SAMPLE_RATE / (SYMBOL_RATE * (1.0 + symbolRateError * 1e-6));
//The decimation filter requires an even sample count
int length = (int)((dibits.size() + PULSE_SPAN_SYMBOLS) * samplesPerSymbol) / 2 * 2;
float[] i = new float[length];
float[] q = new float[length];
//Unit power symbols: the in-band noise variance is the in-band fraction of the total noise variance
double noise = Double.isNaN(carrierToNoise) ? 0.0 :
Math.sqrt(Math.pow(10.0, -carrierToNoise / 10.0) * SAMPLE_RATE / 6000.0 / 2.0);
for(int n = 0; n < length; n++)
{
double t = n / samplesPerSymbol - PULSE_SPAN_SYMBOLS / 2.0; //Symbol periods
double sumI = 0.0;
double sumQ = 0.0;
for(int k = (int)Math.ceil(t - PULSE_SPAN_SYMBOLS / 2.0); k <= t + PULSE_SPAN_SYMBOLS / 2.0; k++)
{
if(k >= 0 && k < dibits.size())
{
double pulse = getRaisedCosine(t - k);
sumI += symbolI[k] * pulse;
sumQ += symbolQ[k] * pulse;
}
}
double carrier = 2.0 * Math.PI * carrierOffset * n / SAMPLE_RATE;
double cos = Math.cos(carrier);
double sin = Math.sin(carrier);
i[n] = (float)(sumI * cos - sumQ * sin + noise * random.nextGaussian());
q[n] = (float)(sumI * sin + sumQ * cos + noise * random.nextGaussian());
}
return new float[][]{i, q};
}
/**
* Raised cosine pulse value
* @param t time in symbol periods
*/
private static double getRaisedCosine(double t)
{
double sinc = Math.abs(t) < 1e-9 ? 1.0 : Math.sin(Math.PI * t) / (Math.PI * t);
double denominator = 1.0 - Math.pow(2.0 * ROLLOFF * t, 2.0);
if(Math.abs(denominator) < 1e-9)
{
return Math.PI / 4.0 * sinc;
}
return sinc * Math.cos(Math.PI * ROLLOFF * t) / denominator;
}
/**
* Adds the most significant bits first, as dibits.
*/
private static void addBits(List<Dibit> dibits, long value, int bitCount)
{
for(int bit = bitCount - 2; bit >= 0; bit -= 2)
{
dibits.add(DIBITS[(int)((value >> bit) & 3)]);
}
}
/**
* Sets the value at the bit offset, most significant bit first.
*/
private static void setValue(BitSet bits, int offset, int bitCount, int value)
{
for(int x = 0; x < bitCount; x++)
{
bits.set(offset + x, ((value >> (bitCount - 1 - x)) & 1) == 1);
}
}
private record Result(int valid, int invalid, int first, int missed) {}
}

View File

@@ -0,0 +1,353 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* JUnit tests for the P25 Phase 1 LSM differential demodulator and differential integrator filter design.
*/
public class P25P1DifferentialDemodulatorLSMTest
{
private static final int SAMPLE_RATE = 24000;
private static final int SAMPLES_PER_SYMBOL = 5;
private static final int BUFFER_LENGTH = 1024;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Synthetic differential QPSK signal generator. Ramps the phase across each symbol period by the symbol's ideal
* phase so that the phase change across each symbol period is the ideal symbol phase, plus a carrier offset.
*/
private static class SignalGenerator
{
private final Random mRandom = new Random(1);
private final double mOffset; //Radians per sample
private final double mNoise;
private double mPhase = 0.0;
private Dibit mSymbol = Dibit.D00_PLUS_1;
private int mSampleCounter = 0;
private Dibit mPreviousSymbol;
SignalGenerator(double offsetHertz, double noise)
{
mOffset = 2.0 * Math.PI * offsetHertz / SAMPLE_RATE;
mNoise = noise;
}
/**
* Generates the next buffer of samples, recording the symbol completed at each sample (or null).
*/
float[][] next(Dibit[] completedSymbols)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
if(mSampleCounter % SAMPLES_PER_SYMBOL == 0)
{
mPreviousSymbol = mSymbol;
mSymbol = DIBITS[mRandom.nextInt(4)];
completedSymbols[x] = mSampleCounter > 0 ? mPreviousSymbol : null;
}
else
{
completedSymbols[x] = null;
}
i[x] = (float)(Math.cos(mPhase) + mRandom.nextGaussian() * mNoise);
q[x] = (float)(Math.sin(mPhase) + mRandom.nextGaussian() * mNoise);
mPhase += mSymbol.getIdealPhase() / SAMPLES_PER_SYMBOL + mOffset;
mSampleCounter++;
}
return new float[][]{i, q};
}
}
private static double toHertz(float radiansPerSample)
{
return radiansPerSample * SAMPLE_RATE / (2.0 * Math.PI);
}
/**
* Test: the integrator taps span one symbol period with the fractional remainder in the final tap and unity DC gain.
*/
@Test
void testDifferentialIntegratorDesign()
{
float[] taps = FilterFactory.getDifferentialIntegrator(5.2);
assertEquals(6, taps.length, "Tap count");
float sum = 0;
for(float tap : taps)
{
sum += tap;
}
assertEquals(1.0, sum, 1e-5, "DC gain");
assertEquals(1.0 / 5.2, taps[0], 1e-6, "Whole tap");
assertEquals(0.2 / 5.2, taps[5], 1e-6, "Fractional tap");
assertEquals(5, FilterFactory.getDifferentialIntegrator(5.0).length, "Integral samples per symbol");
}
/**
* Test: the carrier offset estimate converges and each symbol period's demodulated sample, scaled to radians per
* symbol, decides the transmitted symbol with the offset removed.
*/
@Test
void testOffsetRemovalAndSymbolRecovery()
{
SignalGenerator generator = new SignalGenerator(300, 0.0);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
int symbolCount = 0;
int errorCount = 0;
for(int buffer = 0; buffer < 400; buffer++)
{
float[][] samples = generator.next(completed);
float[] demodulated = demodulator.demodulate(samples[0], samples[1], true);
//Check symbol decisions after the estimator converges
if(buffer >= 300)
{
for(int x = 0; x < BUFFER_LENGTH; x++)
{
if(completed[x] != null)
{
symbolCount++;
if(Dibit.fromSample(demodulated[x] * SAMPLES_PER_SYMBOL) != completed[x])
{
errorCount++;
}
}
}
}
}
assertEquals(300.0, toHertz(demodulator.getFrequencyOffset()), 5.0, "Offset estimate");
assertTrue(symbolCount > 10000, "Symbol count");
assertEquals(0, errorCount, "Symbol errors");
}
/**
* Test: an offset beyond the +/- 600 Hz fourth power estimator range is found by the ambiguity search while
* unsynchronized, and is held once synchronized.
*/
@Test
void testAmbiguitySearch()
{
SignalGenerator generator = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
boolean resolved = false;
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], false);
//Emulate the symbol processor synchronizing once the offset is resolved
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
}
assertTrue(resolved, "Search resolves the offset");
for(int buffer = 0; buffer < 200; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], true);
}
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 10.0, "Offset held while synchronized");
}
/**
* Test: the ambiguity search holds the resolved offset range across a gap with no signal while unsynchronized (ie
* between transmissions on a traffic channel) so that the next transmission starts in the correct offset range.
*/
@Test
void testSearchHoldsAcrossSignalGap()
{
SignalGenerator generator = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
boolean resolved = false;
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], false);
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
}
assertTrue(resolved, "Search resolves the offset");
//Emulate the symbol processor synchronizing to the signal and the framer decoding a valid NID once the offset is
//resolved
for(int buffer = 0; buffer < 20; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], true);
demodulator.offsetVerified();
}
//Gap of ~20,000 symbols (10x the search dwell period) with no signal and no synchronization
Random random = new Random(3);
for(int buffer = 0; buffer < 100; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
i[x] = (float)random.nextGaussian();
q[x] = (float)random.nextGaussian();
}
demodulator.demodulate(i, q, false);
}
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 20.0, "Offset held across the gap");
}
/**
* Test: symbol processor synchronization without a valid NID decode (ie a false sync detection in an incorrect
* offset range) doesn't hold the ambiguity search in the incorrect offset range for the longer dwell period, while
* a verified offset range is held.
*/
@Test
void testUnverifiedSynchronizationDoesNotHoldSearch()
{
SignalGenerator generatorUnverified = new SignalGenerator(-750, 0.1);
SignalGenerator generatorVerified = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM unverified = new P25P1DifferentialDemodulatorLSM();
P25P1DifferentialDemodulatorLSM verified = new P25P1DifferentialDemodulatorLSM();
unverified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
verified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
//Emulate a sync detection in the initial (incorrect) offset range, with and without a valid NID decode
for(int buffer = 0; buffer < 5; buffer++)
{
float[][] samples = generatorUnverified.next(completed);
unverified.demodulate(samples[0], samples[1], true);
samples = generatorVerified.next(completed);
verified.demodulate(samples[0], samples[1], true);
verified.offsetVerified();
}
//Unsynchronized for ~10,000 symbols: longer than the search dwell and shorter than the verified dwell
for(int buffer = 0; buffer < 50; buffer++)
{
float[][] samples = generatorUnverified.next(completed);
unverified.demodulate(samples[0], samples[1], false);
samples = generatorVerified.next(completed);
verified.demodulate(samples[0], samples[1], false);
}
//The unverified search steps to an adjacent (1200 Hz) offset range while the verified search holds its range
double difference = Math.abs(toHertz(unverified.getFrequencyOffset()) - toHertz(verified.getFrequencyOffset()));
assertTrue(difference > 600, "Unverified offset range search stepped away [" + difference + " Hz]");
}
/**
* Test: each sample's reliability is (|x[n]|^2 * |x[n - sps]|^2) / (|x[n]|^2 + |x[n - sps]|^2), including across the
* sample buffer boundary.
*/
@Test
void testReliabilities()
{
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
float[] amplitudes = new float[2 * BUFFER_LENGTH];
for(int x = 0; x < amplitudes.length; x++)
{
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
}
float[] reliabilities = new float[amplitudes.length];
for(int buffer = 0; buffer < 2; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
//Rotate the phase to show that the reliability depends only on the sample power
double phase = x * 0.5;
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
i[x] = (float)(amplitude * Math.cos(phase));
q[x] = (float)(amplitude * Math.sin(phase));
}
demodulator.demodulate(i, q, true);
System.arraycopy(demodulator.getReliabilities(), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
}
for(int x = SAMPLES_PER_SYMBOL; x < amplitudes.length; x++)
{
double current = amplitudes[x] * amplitudes[x];
double delayed = amplitudes[x - SAMPLES_PER_SYMBOL] * amplitudes[x - SAMPLES_PER_SYMBOL];
assertEquals(current * delayed / (current + delayed), reliabilities[x], 1e-5, "Reliability at " + x);
}
}
/**
* Test: the offset estimate doesn't drift when the input is noise.
*/
@Test
void testNoiseDoesNotMoveEstimate()
{
Random random = new Random(2);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
for(int buffer = 0; buffer < 2000; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
i[x] = (float)random.nextGaussian();
q[x] = (float)random.nextGaussian();
}
demodulator.demodulate(i, q, true);
}
assertEquals(0.0, toHertz(demodulator.getFrequencyOffset()), 1e-3, "Offset estimate");
}
}

View File

@@ -0,0 +1,138 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.symbol.Dibit;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the P25 Phase 1 message assembler forced completion of place holder (failed NID) assemblies.
*/
public class P25P1MessageAssemblerTest
{
private static final P25P1DataUnitID TDU = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
private static final P25P1DataUnitID TDULC = P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL;
private static final P25P1DataUnitID TSBK = P25P1DataUnitID.TRUNKING_SIGNALING_BLOCK_1;
/**
* Creates a place holder assembly with the bit length.
*/
private static P25P1MessageAssembler getPlaceHolder(int bitLength)
{
P25P1MessageAssembler assembler = new P25P1MessageAssembler(0x293, P25P1DataUnitID.PLACE_HOLDER);
for(int x = 0; x < bitLength / 2; x++)
{
assembler.receive(Dibit.D01_PLUS_3);
}
return assembler;
}
/**
* Forces completion of a place holder with the bit length.
* @return the identified data unit ID
*/
private static P25P1DataUnitID forceCompletion(int bitLength, P25P1DataUnitID previous, P25P1DataUnitID next)
{
P25P1MessageAssembler assembler = getPlaceHolder(bitLength);
assembler.forceCompletion(previous, next);
return assembler.getDataUnitID();
}
/**
* Test: a place holder that's shorter than a missed TDU frame (ie inter-frame padding or timing slack ahead of the
* next sync) isn't identified as a TDU, even when followed by an LDU1 (HDU resolution) or a TSBK.
*/
@Test
void testShortPlaceHolderDiscarded()
{
for(int length : new int[]{0, 2, 28, 100, 126})
{
P25P1MessageAssembler assembler = getPlaceHolder(length);
assertEquals(0, assembler.forceCompletion(TDULC, TDULC), "Dropped bits at " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(length, P25P1DataUnitID.HEADER_DATA_UNIT,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length " + length + " before LDU1");
}
}
/**
* Test: a place holder with the length of a single missed TDU frame (28 null bits plus the next frame's 112 sync and
* NID bits) is identified as a TDU.
*/
@Test
void testMissedTDU()
{
for(int length : new int[]{128, 136, 140})
{
assertEquals(TDU, forceCompletion(length, TDU, TDU), "Length " + length);
assertEquals(TDU, forceCompletion(length, TDULC, TDU), "Length " + length);
}
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(144, TDU, TDU), "Length 144");
}
/**
* Test: a place holder with the length of a single missed TDULC frame (308 message and null bits plus the next
* frame's 112 sync and NID bits) is identified as a TDULC.
*/
@Test
void testMissedTDULC()
{
for(int length : new int[]{408, 416, 420})
{
assertEquals(TDULC, forceCompletion(length, TDULC, TDULC), "Length " + length);
}
}
/**
* Test: a place holder that doesn't match a missed frame isn't identified as a TDU.
*/
@Test
void testUnidentifiedPlaceHolderDiscarded()
{
for(int length : new int[]{208, 276, 556, 696, 1000})
{
P25P1MessageAssembler assembler = getPlaceHolder(length);
assertEquals(0, assembler.forceCompletion(TDULC, TDU), "Dropped bits at " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
}
//A place holder between voice frames that is too short for an LDU
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(1000, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length 1000 between LDUs");
}
/**
* Test: place holders are resolved from the next data unit ID before the missed frame lengths are applied, so a
* place holder ahead of a TSBK is a TSBK, even when its length matches a missed TDULC frame.
*/
@Test
void testResolvedFromNextDataUnit()
{
assertEquals(TSBK, forceCompletion(350, TSBK, TSBK), "Missed TSBK");
assertEquals(TSBK, forceCompletion(416, TSBK, TSBK), "Missed TSBK with TDULC length");
assertEquals(P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2, forceCompletion(1560,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Missed LDU2");
}
}

View File

@@ -0,0 +1,80 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the P25 Phase 1 LSM symbol processor.
*/
public class P25P1SymbolProcessorLSMTest
{
private static final float SAMPLES_PER_SYMBOL = 5.0f;
private static final double TOLERANCE = 1e-5;
private static P25P1SymbolProcessorLSM getProcessor()
{
P25P1SymbolProcessorLSM processor = new P25P1SymbolProcessorLSM(new P25P1DecoderLSM(), new P25P1MessageFramer());
processor.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
return processor;
}
/**
* Converts radians per symbol to the radians per sample domain of the demodulated sample stream.
*/
private static float toSample(double radiansPerSymbol)
{
return (float)(radiansPerSymbol / SAMPLES_PER_SYMBOL);
}
/**
* Test: samples that don't straddle the phase wrap are linearly interpolated.
*/
@Test
void testLinearInterpolation()
{
P25P1SymbolProcessorLSM processor = getProcessor();
assertEquals(toSample(1.5), processor.interpolate(toSample(1.0), toSample(2.0), 0.5), TOLERANCE);
assertEquals(toSample(-1.25), processor.interpolate(toSample(-1.0), toSample(-2.0), 0.25), TOLERANCE);
}
/**
* Test: samples that straddle the phase wrap near an outer symbol interpolate across the wrap instead of through
* zero, and the result is wrapped to +/- PI radians per symbol.
*/
@Test
void testInterpolationAcrossPhaseWrap()
{
P25P1SymbolProcessorLSM processor = getProcessor();
//+3.0 and -3.0 are 0.283 radians apart across the wrap at +/- PI.
assertEquals(toSample(Math.PI), Math.abs(processor.interpolate(toSample(3.0), toSample(-3.0), 0.5)), TOLERANCE);
//A quarter of the way from +3.0 toward -3.0 (across the wrap) is still on the positive side of the wrap.
double expected = 3.0 + 0.25 * (2.0 * Math.PI - 6.0);
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.25), TOLERANCE);
//Three quarters of the way crosses the wrap to the negative side.
expected = 3.0 + 0.75 * (2.0 * Math.PI - 6.0) - 2.0 * Math.PI;
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.75), TOLERANCE);
}
}

View File

@@ -0,0 +1,117 @@
/*
* *****************************************************************************
* 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.p25.phase1.message;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.module.decode.p25.phase1.P25P1DataUnitID;
import io.github.dsheirer.module.decode.p25.phase1.message.hdu.HDUMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.tdu.TDULCMessage;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* JUnit tests for the message validity of P25 Phase 1 messages that decode their error protected content on demand.
* The validity must reflect the error correction result on the first isValid() call, before any other accessor has
* decoded the content.
*/
public class P25P1MessageValidityTest
{
/**
* Creates a message with all-zero bits (a valid codeword for the linear Golay and Reed-Solomon codes).
*/
private static CorrectedBinaryMessage getZeroMessage(P25P1DataUnitID dataUnitID)
{
CorrectedBinaryMessage message = new CorrectedBinaryMessage(dataUnitID.getMessageLength());
message.setSize(dataUnitID.getMessageLength());
return message;
}
/**
* Creates a message with random bits.
*/
private static CorrectedBinaryMessage getRandomMessage(P25P1DataUnitID dataUnitID, long seed)
{
Random random = new Random(seed);
CorrectedBinaryMessage message = getZeroMessage(dataUnitID);
for(int x = 0; x < message.size(); x++)
{
message.set(x, random.nextBoolean());
}
return message;
}
/**
* Test: a TDULC is valid when its link control word passes error correction.
*/
@Test
void testValidTDULC()
{
TDULCMessage tdulc = new TDULCMessage(getZeroMessage(P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL),
0x293, 0L);
assertTrue(tdulc.isValid(), "TDULC");
assertTrue(tdulc.getLinkControlWord().isValid(), "Link control word");
}
/**
* Test: a TDULC is invalid when its link control word fails error correction.
*/
@Test
void testInvalidTDULC()
{
for(long seed = 1; seed <= 10; seed++)
{
TDULCMessage tdulc = new TDULCMessage(getRandomMessage(
P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL, seed), 0x293, 0L);
assertFalse(tdulc.isValid(), "TDULC with random bits, seed " + seed);
assertFalse(tdulc.getLinkControlWord().isValid(), "Link control word, seed " + seed);
}
}
/**
* Test: an HDU is valid when its header data passes error correction.
*/
@Test
void testValidHDU()
{
HDUMessage hdu = new HDUMessage(getZeroMessage(P25P1DataUnitID.HEADER_DATA_UNIT), 0x293, 0L);
assertTrue(hdu.isValid(), "HDU");
assertTrue(hdu.getHeaderData().isValid(), "Header data");
}
/**
* Test: an HDU is invalid when its header data fails error correction, including when isValid() is invoked before
* the header data is accessed.
*/
@Test
void testInvalidHDU()
{
for(long seed = 1; seed <= 10; seed++)
{
HDUMessage hdu = new HDUMessage(getRandomMessage(P25P1DataUnitID.HEADER_DATA_UNIT, seed), 0x293, 0L);
assertFalse(hdu.isValid(), "HDU with random bits, seed " + seed);
assertFalse(hdu.getHeaderData().isValid(), "Header data, seed " + seed);
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* *****************************************************************************
* 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.source.tuner.frequency;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the channel frequency error manager.
*/
public class ChannelFrequencyErrorManagerTest
{
/**
* Test: a tuner PPM change is removed from the channel's applied frequency correction, and the updated correction
* is applied to the channel source.
*/
@Test
void testTunerPPMChange()
{
TestChannelSource source = new TestChannelSource();
ChannelFrequencyErrorManager manager = new ChannelFrequencyErrorManager(source, null);
manager.receive(-10);
assertEquals(10, manager.getFrequencyError(), "Applied correction");
assertEquals(10, source.mFrequencyCorrection, "Channel source correction");
manager.receive(25);
assertEquals(-15, manager.getFrequencyError(), "Applied correction");
assertEquals(-15, source.mFrequencyCorrection, "Channel source correction");
}
/**
* Channel source that records the applied frequency correction.
*/
private static class TestChannelSource extends TunerChannelSource
{
private long mFrequencyCorrection = Long.MIN_VALUE;
public TestChannelSource()
{
super(null, new TunerChannel(460_000_000L, 12_500), "test", null);
}
@Override
public void setFrequencyCorrection(long correction)
{
mFrequencyCorrection = correction;
}
@Override
public void setFrequency(long frequency)
{
}
@Override
protected void setSampleRate(double sampleRate)
{
}
@Override
public void setListener(Listener<ComplexSamples> complexSamplesListener)
{
}
@Override
public double getSampleRate()
{
return 50_000.0;
}
@Override
public long getFrequency()
{
return 460_000_000L;
}
@Override
public void reset()
{
}
}
}