#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

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