BASEBAND_FILTERS = new ConcurrentHashMap<>();
+ private IDemodulator mDemodulator;
private final DMRMessageFramer mMessageFramer;
- private final DMRSoftSymbolProcessor mSymbolProcessor;
+ private final DMRSymbolProcessor mSymbolProcessor;
private final DMRMessageProcessor mMessageProcessor;
- private IRealFilter mIBasebandFilter;
- private IRealFilter mQBasebandFilter;
+ private IRealFilter mBasebandFilterI;
+ private IRealFilter mBasebandFilterQ;
private IRealDecimationFilter mDecimationFilterI;
private IRealDecimationFilter mDecimationFilterQ;
- private RealFIRFilter mRRCFilterI;
- private RealFIRFilter mRRCFilterQ;
+ private IRealFilter mReceiveFilter;
/**
* Constructs an instance
@@ -106,7 +107,7 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
{
DMRCrcMaskManager crcMaskManager = new DMRCrcMaskManager(config.getIgnoreCRCChecksums());
mMessageFramer = new DMRMessageFramer(crcMaskManager);
- mSymbolProcessor = new DMRSoftSymbolProcessor(mMessageFramer, this);
+ mSymbolProcessor = new DMRSymbolProcessor(this, mMessageFramer);
mMessageProcessor = new DMRMessageProcessor(config, crcMaskManager);
mMessageProcessor.setMessageListener(getMessageListener());
setSampleRate(25000.0);
@@ -129,21 +130,20 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
*/
public void setSampleRate(double sampleRate)
{
- if(sampleRate <= SYMBOL_RATE * 2)
- {
- throw new IllegalArgumentException("Sample rate [" + sampleRate + "] must be >9600 (2 * " +
- SYMBOL_RATE + " symbol rate)");
- }
+ float samplesPerSymbol = (float)sampleRate / SYMBOL_RATE;
- mIBasebandFilter = FilterFactory.getRealFilter(getBasebandFilter(sampleRate));
- mQBasebandFilter = FilterFactory.getRealFilter(getBasebandFilter(sampleRate));
+ if(samplesPerSymbol <= 4.0f)
+ {
+ throw new IllegalArgumentException("Sample rate [" + sampleRate + "] must be at least " + (SYMBOL_RATE * 4) + " Hz");
+ }
int decimation = 1;
- //Identify decimation that gets us as close to 4.0 Samples Per Symbol as possible (19.2 kHz)
- while((sampleRate / decimation) >= 38400)
+ //Identify decimation that gets us as close to 5.0 Samples Per Symbol as possible
+ while(samplesPerSymbol >= 10.0)
{
decimation *= 2;
+ samplesPerSymbol /= 2;
}
mDecimationFilterI = DecimationFilterFactory.getRealDecimationFilter(decimation);
@@ -154,21 +154,21 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
//Set the decimated sample rate to use for PLL error reporting.
setDecimatedSampleRate(decimatedSampleRate);
- float rrcAlpha = Math.abs((float)(5760.0 / decimatedSampleRate));
- int symbolLength = (int)Math.floor((-44 * rrcAlpha) + 33);
- symbolLength += symbolLength % 2; //Make the symbol length even
-
- if(symbolLength < 0)
+ //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++)
{
- symbolLength = 2;
+ taps[x] = (float)receiveFilterTaps[x];
}
+ mReceiveFilter = new RealFIRFilter(taps);
- float[] taps = FilterFactory.getRootRaisedCosine(decimatedSampleRate / SYMBOL_RATE, symbolLength, rrcAlpha);
- mRRCFilterI = new RealFIRFilter(taps);
- mRRCFilterQ = new RealFIRFilter(taps);
+ mBasebandFilterI = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
+ mBasebandFilterQ = FilterFactory.getRealFilter(getBasebandFilter(decimatedSampleRate));
- mDemodulator = DifferentialDemodulatorFactory.getFloatDemodulator(decimatedSampleRate, SYMBOL_RATE);
- mSymbolProcessor.setSamplesPerSymbol(mDemodulator.getSamplesPerSymbol());
+ mDemodulator = FmDemodulatorFactory.getFmDemodulator();
+ mSymbolProcessor.setSamplesPerSymbol(samplesPerSymbol);
mMessageFramer.setListener(mMessageProcessor);
mMessageProcessor.setMessageListener(getMessageListener());
}
@@ -181,19 +181,20 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
public void receive(ComplexSamples samples)
{
+ //Update the message framer with the timestamp from the incoming sample buffer.
mMessageFramer.setTimestamp(samples.timestamp());
- float[] i = mIBasebandFilter.filter(samples.i());
- float[] q = mQBasebandFilter.filter(samples.q());
+ float[] i = mDecimationFilterI.decimateReal(samples.i());
+ float[] q = mDecimationFilterQ.decimateReal(samples.q());
- i = mDecimationFilterI.decimateReal(i);
- q = mDecimationFilterQ.decimateReal(q);
-
- i = mRRCFilterI.filter(i);
- q = mRRCFilterQ.filter(q);
+ i = mBasebandFilterI.filter(i);
+ q = mBasebandFilterQ.filter(q);
float[] demodulated = mDemodulator.demodulate(i, q);
- mSymbolProcessor.receive(demodulated);
+ demodulated = mReceiveFilter.filter(demodulated);
+
+ //Process demodulated samples into symbols and apply message sync detection and framing.
+ mSymbolProcessor.process(demodulated);
}
/**
@@ -201,9 +202,12 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
*/
private float[] getBasebandFilter(double sampleRate)
{
- if(BASEBAND_FILTERS.containsKey(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 BASEBAND_FILTERS.get(sampleRate);
+ return cached;
}
FIRFilterSpecification specification = FIRFilterSpecification
@@ -219,11 +223,15 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
try
{
coefficients = FilterFactory.getTaps(specification);
- BASEBAND_FILTERS.put(sampleRate, coefficients);
+
+ if(coefficients != null)
+ {
+ BASEBAND_FILTERS.put(sampleRate, coefficients);
+ }
}
- catch(Exception fde) //FilterDesignException
+ catch(Exception e) //FilterDesignException or runtime failures from the Remez designer
{
- System.out.println("Error");
+ LOGGER.error("Error designing DMR baseband filter for sample rate [" + sampleRate + "]", e);
}
if(coefficients == null)
@@ -270,7 +278,7 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
@Override
public String getProtocolDescription()
{
- return "DMR C4FM";
+ return "DMR";
}
@Override
@@ -285,6 +293,7 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
{
super.stop();
mMessageFramer.stop();
+ mSymbolProcessor.dispose();
}
/**
@@ -296,6 +305,10 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
{
switch(sourceEvent.getEvent())
{
+ case NOTIFICATION_FREQUENCY_CHANGE:
+ case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
+ mSymbolProcessor.resetBalance();
+ break;
case NOTIFICATION_SAMPLE_RATE_CHANGE:
setSampleRate(sourceEvent.getValue().doubleValue());
break;
@@ -314,8 +327,8 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
LOGGER.info("Starting ...");
// String directory = "D:\\DQPSK Equalizer Research\\"; //Windows
- String directory = "/media/denny/T9/DQPSK Equalizer Research/"; //Linux
- String file = directory + "DMR_1_CAPPLUS.wav";
+ String directory = "/run/media/denny/T9/DQPSK Equalizer Research/"; //Linux
+// String file = directory + "DMR_1_CAPPLUS.wav";
// String file = directory + "DMR_2_CAPPLUS.wav";
// String file = directory + "DMR_3_CAPPLUS.wav";
// String file = directory + "20230819_064211_451250000_SaiaNet_Syracuse_Control_29_baseband.wav";
@@ -325,7 +338,7 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider,
// String file = directory + "DMR_6_20241217_031511_451425000_SaiaNet_Onondaga_SaiaNet_Control_50_baseband.wav";
// String file = directory + "DMR_7_20241217_031651_451250000_SaiaNet_Onondaga_SaiaNet_Control_50_baseband.wav";
// String file = directory + "DMR_8_20241217_031845_461662500_SaiaNet_(Tier_III)_Onondaga_Control_25_baseband.wav";
-// String file = directory + "DMR_9_CAPPLUS_encrypted_American_Airlines_Maricopa_Control_29_baseband.wav";
+ String file = directory + "DMR_9_CAPPLUS_encrypted_American_Airlines_Maricopa_Control_29_baseband.wav";
// String file = directory + "DMR_10_CAP_ENCRYPTED_20241222_035408_935487500_American_Airlines_Maricopa_Control_1_baseband.wav";
// String file = directory + "DMR_12_20250420_061639_451250000_SaiaNet_Onondaga_SaiaNet-Control_1_baseband.wav";
// String file = directory + "DMR_17_20250516_053150_451425000_SaiaNet_Onondaga_SaiaNet-Control_1_baseband.wav";
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramer.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramer.java
index 8ba6ae6a..b898e659 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramer.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramer.java
@@ -20,7 +20,9 @@
package io.github.dsheirer.module.decode.dmr;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.dsp.c4fm.C4FMMessageFramer;
import io.github.dsheirer.dsp.symbol.Dibit;
+import io.github.dsheirer.dsp.symbol.DibitDelayLine;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.SyncLossMessage;
import io.github.dsheirer.module.decode.dmr.message.CACH;
@@ -38,14 +40,34 @@ import java.util.Arrays;
*
* This framer is able to detect DMR Direct mode and track one active and one inactive timeslot across each voice
* super frame in addition to framing base and mobile station data and voice bursts.
+ *
+ * Supports two symbol stream inputs:
+ *
+ * - Listener receive(Dibit) and syncDetected(DMRSyncPattern) where the sync detection is signalled when the
+ * next dibit is the first dibit of the burst (ie the symbol processor maintains a burst delay line)
+ * - C4FM message framer process(Dibit) and syncDetected() where the sync detection is signalled when the most
+ * recent dibit is the final dibit of the sync pattern. This framer maintains a delay line to retain the burst
+ * dibits from CACH through the sync pattern and the sync pattern must be provided via setSyncPattern() prior to
+ * the sync detection notification.
+ *
*/
-public class DMRMessageFramer implements Listener
+public class DMRMessageFramer extends C4FMMessageFramer implements Listener
{
private static final int DIBIT_CACH_START = 0;
private static final int DIBIT_BURST_END = 144;
+ //Dibit delay line sizing: CACH(12) + MESSAGE_PREFIX(54) + SYNC(24)
+ private static final int DELAY_LINE_LENGTH = 90;
+ private final DibitDelayLine mDibitDelayLine = new DibitDelayLine(DELAY_LINE_LENGTH);
+ //Soft symbols (radians) delay line parallel to the dibit delay line, or NaN when the soft symbol wasn't provided
+ private final float[] mSoftSymbolDelayLine = new float[DELAY_LINE_LENGTH];
+ private int mSoftSymbolDelayLinePointer = 0;
+ private DMRSyncPattern mSyncPattern = DMRSyncPattern.UNKNOWN;
private Listener mMessageListener;
private final Dibit[] mBufferA = new Dibit[144];
private final Dibit[] mBufferB = new Dibit[144];
+ //Soft symbols (radians) parallel to the dibit buffers, or NaN when the soft symbol wasn't provided
+ private final float[] mSoftBufferA = new float[144];
+ private final float[] mSoftBufferB = new float[144];
private int mBufferAPointer = 0;
private int mBufferBPointer = 0;
private DMRSyncPattern mBufferAPattern = DMRSyncPattern.UNKNOWN;
@@ -67,15 +89,80 @@ public class DMRMessageFramer implements Listener
{
Arrays.fill(mBufferA, Dibit.D00_PLUS_1);
Arrays.fill(mBufferB, Dibit.D00_PLUS_1);
+ Arrays.fill(mSoftSymbolDelayLine, Float.NaN);
+ Arrays.fill(mSoftBufferA, Float.NaN);
+ Arrays.fill(mSoftBufferB, Float.NaN);
mMessageFactory = new DMRMessageFactory(crcMaskManager);
}
+ /**
+ * Sets the sync pattern for the next C4FM sync detection notification via syncDetected().
+ * @param pattern that was detected.
+ */
+ public void setSyncPattern(DMRSyncPattern pattern)
+ {
+ mSyncPattern = pattern;
+ }
+
+ /**
+ * C4FM symbol stream input method. Dibits are delayed via the burst delay line before framing.
+ * @param symbol to process
+ * @return true if a valid message was dispatched.
+ */
+ @Override
+ public boolean process(Dibit symbol)
+ {
+ return process(symbol, Float.NaN);
+ }
+
+ /**
+ * C4FM symbol stream input method. Dibits and soft symbols are delayed via the burst delay lines before framing.
+ * @param symbol decision
+ * @param softSymbol equalized soft symbol in radians
+ * @return true if a valid message was dispatched.
+ */
+ @Override
+ public boolean process(Dibit symbol, float softSymbol)
+ {
+ float ejectedSoftSymbol = mSoftSymbolDelayLine[mSoftSymbolDelayLinePointer];
+ mSoftSymbolDelayLine[mSoftSymbolDelayLinePointer++] = softSymbol;
+ mSoftSymbolDelayLinePointer %= DELAY_LINE_LENGTH;
+ return processDibit(mDibitDelayLine.insert(symbol), ejectedSoftSymbol);
+ }
+
+ /**
+ * C4FM sync detection notification where the most recently processed dibit is the final dibit of the sync pattern
+ * that was set via setSyncPattern(). Overwrites the sync dibits in the delay line with the detected sync pattern
+ * to eliminate any sync bit errors and signals that the next dibit ejected from the delay line is the first dibit
+ * of the burst.
+ */
+ @Override
+ public void syncDetected()
+ {
+ if(DMRSyncPattern.SYNC_PATTERNS.contains(mSyncPattern))
+ {
+ mDibitDelayLine.update(mSyncPattern.toDibits());
+ syncDetected(mSyncPattern);
+ }
+ }
+
/**
* Primary input method for receiving the stream of demodulated dibit symbols.
* @param dibit to process.
*/
@Override
public void receive(Dibit dibit)
+ {
+ processDibit(dibit, Float.NaN);
+ }
+
+ /**
+ * Processes the dibit
+ * @param dibit to process
+ * @param softSymbol for the dibit in radians, or NaN.
+ * @return true if a valid message was dispatched.
+ */
+ private boolean processDibit(Dibit dibit, float softSymbol)
{
mDibitCounter++;
mDibitSinceTimestampCounter++;
@@ -93,23 +180,27 @@ public class DMRMessageFramer implements Listener
{
if(mBufferAActive)
{
+ mSoftBufferA[mBufferAPointer] = softSymbol;
mBufferA[mBufferAPointer++] = dibit;
if(mBufferAPointer >= mBufferA.length)
{
- dispatchBufferA();
+ return dispatchBufferA();
}
}
else
{
+ mSoftBufferB[mBufferBPointer] = softSymbol;
mBufferB[mBufferBPointer++] = dibit;
if(mBufferBPointer >= mBufferB.length)
{
- dispatchBufferB();
+ return dispatchBufferB();
}
}
}
+
+ return false;
}
/**
@@ -121,7 +212,11 @@ public class DMRMessageFramer implements Listener
(!mBufferAActive && mBufferBPattern.isVoicePattern()));
}
- private void dispatchBufferA()
+ /**
+ * Dispatches the burst assembled in buffer A.
+ * @return true if the dispatched burst message is valid.
+ */
+ private boolean dispatchBufferA()
{
mDibitCounter -= 144;
@@ -150,7 +245,8 @@ public class DMRMessageFramer implements Listener
mDibitCounter = 0;
CorrectedBinaryMessage message = getMessage(mBufferA);
- CACH cach = CACH.getCACH(message);
+ float[] softBits = getSoftBits(mSoftBufferA);
+ CACH cach = CACH.getCACH(message, softBits);
if(mBufferAPattern.hasCACH() && cach.isValid() && mBufferATimeslot != cach.getTimeslot())
{
@@ -167,7 +263,9 @@ public class DMRMessageFramer implements Listener
}
}
- dispatch(mMessageFactory.create(mBufferAPattern, message, cach, getTimestamp(), mBufferATimeslot));
+ IMessage burstMessage = mMessageFactory.create(mBufferAPattern, message, softBits, cach,
+ getTimestamp(), mBufferATimeslot);
+ dispatch(burstMessage);
//Since voice frames only have sync on the first burst, we use pseudo-patterns to track the rest of the bursts
//across the voice super-frame. If the current pattern is a voice frame, set it to the next pseudo voice sync.
@@ -187,9 +285,15 @@ public class DMRMessageFramer implements Listener
{
mAssemblingBurst = false;
}
+
+ return burstMessage != null && burstMessage.isValid();
}
- private void dispatchBufferB()
+ /**
+ * Dispatches the burst assembled in buffer B.
+ * @return true if the dispatched burst message is valid.
+ */
+ private boolean dispatchBufferB()
{
mDibitCounter -= 144;
@@ -218,7 +322,8 @@ public class DMRMessageFramer implements Listener
mDibitCounter = 0;
CorrectedBinaryMessage burst = getMessage(mBufferB);
- CACH cach = CACH.getCACH(burst);
+ float[] softBits = getSoftBits(mSoftBufferB);
+ CACH cach = CACH.getCACH(burst, softBits);
if(mBufferBPattern.hasCACH() && cach.isValid() && mBufferBTimeslot != cach.getTimeslot())
{
@@ -236,7 +341,9 @@ public class DMRMessageFramer implements Listener
}
}
- dispatch(mMessageFactory.create(mBufferBPattern, burst, cach, getTimestamp(), mBufferBTimeslot));
+ IMessage burstMessage = mMessageFactory.create(mBufferBPattern, burst, softBits, cach,
+ getTimestamp(), mBufferBTimeslot);
+ dispatch(burstMessage);
//Since voice frames only have sync on the first burst, we use pseudo-patterns to track the rest of the bursts
//across the voice super-frame. If the current pattern is a voice frame, set it to the next pseudo voice sync.
@@ -256,6 +363,8 @@ public class DMRMessageFramer implements Listener
{
mAssemblingBurst = false;
}
+
+ return burstMessage != null && burstMessage.isValid();
}
/**
@@ -295,6 +404,28 @@ public class DMRMessageFramer implements Listener
mMessageListener = listener;
}
+ /**
+ * Converts the soft symbol buffer into soft bits (one per message bit) where a positive value favors a 1 bit.
+ * @param softSymbols buffer
+ * @return soft bits or null if any of the soft symbols were not provided.
+ */
+ private static float[] getSoftBits(float[] softSymbols)
+ {
+ float[] softBits = new float[softSymbols.length * 2];
+
+ for(int x = 0; x < softSymbols.length; x++)
+ {
+ if(Float.isNaN(softSymbols[x]))
+ {
+ return null;
+ }
+
+ setSoftBits(softSymbols[x], softBits, x * 2);
+ }
+
+ return softBits;
+ }
+
/**
* Creates a binary message from the dibit buffer.
*
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java
index 23fd9e71..93d8ba68 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRMessageProcessor.java
@@ -187,20 +187,21 @@ public class DMRMessageProcessor implements Listener
if(message.getTimeslot() == 1)
{
FullLCMessage flco = mFLCAssemblerTimeslot1.process(voice.getEMB().getLCSS(),
- voice.getFLCFragment(), message.getTimestamp());
+ voice.getFLCFragment(), voice.getFLCFragmentSoftBits(), message.getTimestamp());
receive(flco);
}
else
{
FullLCMessage flco = mFLCAssemblerTimeslot2.process(voice.getEMB().getLCSS(),
- voice.getFLCFragment(), message.getTimestamp());
+ voice.getFLCFragment(), voice.getFLCFragmentSoftBits(), message.getTimestamp());
receive(flco);
}
if(voice.hasCACH())
{
CACH cach = voice.getCACH();
- ShortLCMessage slco = mSLCAssembler.process(cach.getLCSS(), cach.getPayload(), message.getTimestamp());
+ ShortLCMessage slco = mSLCAssembler.process(cach.getLCSS(), cach.getPayload(),
+ cach.getPayloadSoftBits(), message.getTimestamp());
receive(slco);
}
}
@@ -210,7 +211,8 @@ public class DMRMessageProcessor implements Listener
if(dmrBurst.hasCACH())
{
CACH cach = dmrBurst.getCACH();
- ShortLCMessage slco = mSLCAssembler.process(cach.getLCSS(), cach.getPayload(), message.getTimestamp());
+ ShortLCMessage slco = mSLCAssembler.process(cach.getLCSS(), cach.getPayload(),
+ cach.getPayloadSoftBits(), message.getTimestamp());
receive(slco);
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSoftSymbolProcessor.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSoftSymbolProcessor.java
deleted file mode 100644
index 19251a86..00000000
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSoftSymbolProcessor.java
+++ /dev/null
@@ -1,766 +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
- * ****************************************************************************
- */
-
-package io.github.dsheirer.module.decode.dmr;
-
-import io.github.dsheirer.dsp.filter.interpolator.LinearInterpolator;
-import io.github.dsheirer.dsp.symbol.Dibit;
-import io.github.dsheirer.dsp.symbol.DibitDelayLine;
-import io.github.dsheirer.dsp.symbol.DibitToByteBufferAssembler;
-import io.github.dsheirer.gui.viewer.sync.SyncResultsViewer;
-import io.github.dsheirer.module.decode.FeedbackDecoder;
-import io.github.dsheirer.module.decode.dmr.sync.DMRSoftSyncDetector;
-import io.github.dsheirer.module.decode.dmr.sync.DMRSoftSyncDetectorFactory;
-import io.github.dsheirer.module.decode.dmr.sync.DMRSyncDetectMode;
-import io.github.dsheirer.module.decode.dmr.sync.DMRSyncModeMonitor;
-import io.github.dsheirer.module.decode.dmr.sync.DMRSyncPattern;
-import io.github.dsheirer.sample.Listener;
-import java.nio.ByteBuffer;
-import java.util.Arrays;
-import java.util.concurrent.CountDownLatch;
-import org.apache.commons.math3.stat.descriptive.moment.StandardDeviation;
-
-/**
- * DMR soft symbol processor. Processes a stream of differentially decoded samples to align to the symbol timing and
- * symbol period and extract symbols from the sample stream.
- *
- * Uses two soft sync correlation detectors to achieve initial fine sync with a primary sync detector and a lagging
- * sync detector sampling at 1/2 the symbol period to ensure the sync is detected while only calculating at each symbol
- * period interval. Once a sync is detected, fine sync is declared and the lagging sync detector is disabled. Fine sync
- * disables whenever the quantity of processed symbols exceeds one DMR burst length and the lagging detector
- * re-enables.
- *
- * The sync mode monitor processes the sync detections to identify the dominant sync mode for the channel and then
- * disables processing of the two unused sync modes (BASE STATION, MOBILE STATION, or DIRECT MODE) for the remainder
- * of the channel.
- *
- * This processor uses a symbol delay buffer (dibit delay line) to retain a partial DMR burst from CACH up through the
- * sync field of the burst. This allows the optimize method to resample the burst symbols once the sync is detected and
- * optimal symbol timing and symbol period are calculated. This ensures the best symbol decisions are then passed to
- * the message framer along with the sync detection.
- *
- * Buffer Structure & Size = 129 Dibits: [2 pad][12 CACH][54 Message Prefix][24 Sync][24 workspace][1 pad]
- *
- * The equalizer calculates balance and gain values to apply to the differentially decoded sample stream to optimize
- * the constellation skew and openness. The equalizer is updated at each quality sync detection and then the equalizer
- * values are applied across the entire burst through to the next sync detection. DMR sync patterns are made up of
- * equal quantities of +3 and -3 symbols which support the equalizer update approach.
- */
-public class DMRSoftSymbolProcessor
-{
- private static final int BUFFER_PROTECTED_REGION_DIBITS = 92;
- private static final int BUFFER_WORKSPACE_LENGTH_DIBITS = 25; //This can be adjusted for efficiency
- private static final int BUFFER_LENGTH_DIBITS = BUFFER_PROTECTED_REGION_DIBITS + BUFFER_WORKSPACE_LENGTH_DIBITS;
- private static final float EQUALIZER_LOOP_GAIN = 0.15f;
- private static final float MAXIMUM_EQUALIZER_BALANCE = (float)(Math.PI / 3.0);
- private static final float MAXIMUM_EQUALIZER_GAIN = 1.25f;
- private static final float MAXIMUM_POSITIVE_SAMPLE_PHASE = 3.5f;
- private static final float MAXIMUM_NEGATIVE_SAMPLE_PHASE = -3.5f;
- private static final float SAMPLES_PER_SYMBOL_ALLOWABLE_DEVIATION = 0.005f; //.5%
- private static final float SYMBOL_QUADRANT_BOUNDARY = (float)(Math.PI / 2.0);
- private static final float SYNC_DETECTION_THRESHOLD = 60;
- private static final float SYNC_OPTIMIZED_THRESHOLD = 80;
- private static final float SYNC_EQUALIZED_THRESHOLD = 100;
- private static final float TWO_PI = (float)(Math.PI * 2.0);
- private DMRSyncModeMonitor mSyncModeMonitor = new DMRSyncModeMonitor();
- private DMRSoftSyncDetector mSyncDetector = DMRSoftSyncDetectorFactory.getDetector();
- private DMRSoftSyncDetector mSyncDetectorSecondary = DMRSoftSyncDetectorFactory.getDetector();
- private DibitToByteBufferAssembler mDibitAssembler = new DibitToByteBufferAssembler(300);
- //Dibit delay line sizing: CACH(12) + MESSAGE_PREFIX(54) + SYNC(24)
- private DibitDelayLine mDibitDelayLine = new DibitDelayLine(90);
- private DMRMessageFramer mMessageFramer;
- private boolean mFineSync = false;
- private boolean mEqualizerInitialized = false;
- private double mNoiseStandardDeviationThreshold;
- private double mSecondarySyncOffset;
- private double mSamplesPerSymbol;
- private double mObservedSamplesPerSymbol;
- private double mSamplePoint;
- private float mEqualizerBalance = 0.0f;
- private float mEqualizerGain = 1.0f;
- private float mOptimizeFineIncrement;
- private float[] mBuffer;
- private int mBufferInterpolatorReservedRegion;
- private int mBufferLoadPointer;
- private int mBufferPointer;
- private int mBufferWorkspaceLength;
- private int mSymbolsSinceLastSync = 0;
- private SyncResultsViewer mSyncResultsViewer;
- private FeedbackDecoder mFeedbackDecoder;
-
- /**
- * Constructs an instance
- * @param messageFramer to receive demodulated symbols/dibits and sync detect notifications
- */
- public DMRSoftSymbolProcessor(DMRMessageFramer messageFramer, FeedbackDecoder feedbackDecoder)
- {
- if(messageFramer == null)
- {
- throw new IllegalArgumentException("Message framer cannot be null");
- }
-
- mMessageFramer = messageFramer;
- mFeedbackDecoder = feedbackDecoder;
- mSyncModeMonitor.add(mSyncDetector);
- mSyncModeMonitor.add(mSyncDetectorSecondary);
- }
-
- /**
- * Sets base station sync detection mode. This disables detection for MOBILE and DIRECT sync patterns. This can
- * be used for traffic channels that are known to be part of a base station.
- * @param enabled true for base mode only, false otherwise.
- */
- public void setBaseStationMode(boolean enabled)
- {
- mSyncModeMonitor.setMode(enabled ? DMRSyncDetectMode.BASE_ONLY : DMRSyncDetectMode.AUTOMATIC);
- }
-
- /**
- * Registers the listener to receive demodulated bit stream buffers.
- * @param listener to register
- */
- public void setBufferListener(Listener listener)
- {
- mDibitAssembler.setBufferListener(listener);
- }
-
- /**
- * Indicates if there is a registered buffer listener
- */
- public boolean hasBufferListener()
- {
- return mDibitAssembler.hasBufferListeners();
- }
-
- /**
- * Sets the reference timestamp from the incoming sample stream to use for assigning a timestamp to framed messages.
- * @param timestamp to set for reference
- */
- public void setTimestamp(long timestamp)
- {
- mMessageFramer.setTimestamp(timestamp);
- }
-
- /**
- * Primary input method for receiving a stream of demodulated samples to process into symbols.
- * @param samples to process
- */
- public void receive(float[] samples)
- {
- int samplesPointer = 0;
-
- while(samplesPointer < samples.length)
- {
- if(mBufferLoadPointer == mBuffer.length)
- {
- System.arraycopy(mBuffer, mBufferWorkspaceLength, mBuffer, 0, mBuffer.length - mBufferWorkspaceLength);
- mBufferLoadPointer -= mBufferWorkspaceLength;
- mBufferPointer -= mBufferWorkspaceLength;
- }
-
- int copyLength = Math.min(mBuffer.length - mBufferLoadPointer, samples.length - samplesPointer);
- System.arraycopy(samples, samplesPointer, mBuffer, mBufferLoadPointer, copyLength);
- samplesPointer += copyLength;
- mBufferLoadPointer += copyLength;
-
- for(int x = mBufferPointer; x < mBufferLoadPointer; x++)
- {
- //Unwrap phases
- if(mBuffer[x - 1] > 1.5f && mBuffer[x] < -1.5f)
- {
- mBuffer[x] += TWO_PI;
- }
- else if(mBuffer[x - 1] < -1.5f && mBuffer[x] > 1.5f)
- {
- mBuffer[x] -= TWO_PI;
- }
-
- //Apply equalizer adjustments
- mBuffer[x] += mEqualizerBalance;
- mBuffer[x] *= mEqualizerGain;
-
- //Allow the equalized buffer samples to exceed PI (3.14) by a small factor (3.5) to ensure the optimize
- // and equalizer update functions work correctly. The toSymbol() method will correctly map any symbols
- // that exceed +/- PI into the correct quadrant.
- mBuffer[x] = Math.min(mBuffer[x], MAXIMUM_POSITIVE_SAMPLE_PHASE);
- mBuffer[x] = Math.max(mBuffer[x], MAXIMUM_NEGATIVE_SAMPLE_PHASE);
- }
-
- float softSymbol, primaryScore, secondaryScore;
- double secondary;
- int secondaryIntegral;
-
- while(mBufferPointer < (mBufferLoadPointer - mBufferInterpolatorReservedRegion))
- {
- mBufferPointer++;
- mSamplePoint--;
-
- if(mSamplePoint < 1)
- {
- if(mSymbolsSinceLastSync > 144)
- {
- mFineSync = false;
- }
-
- softSymbol = LinearInterpolator.calculate(mBuffer[mBufferPointer], mBuffer[mBufferPointer + 1], mSamplePoint);
- mFeedbackDecoder.broadcast(softSymbol);
- mSyncDetector.process(softSymbol);
- Dibit symbol = toSymbol(softSymbol);
-
- //Store the symbol in the delay line and broadcast the delayed ejected symbol to the message framer
- //and the bitstream assembler.
- Dibit ejected = mDibitDelayLine.insert(symbol);
- mMessageFramer.receive(ejected);
- mDibitAssembler.receive(ejected);
- mSymbolsSinceLastSync++;
-
- if(mFineSync)
- {
- if(mSymbolsSinceLastSync >= 144)
- {
- if(mMessageFramer.isVoiceSuperFrame())
- {
- mSymbolsSinceLastSync -= 144;
- }
- else
- {
- primaryScore = mSyncDetector.calculate();
-
- //Debug - visualize the sync to show samples and symbol timing
-// if(primaryScore > SYNC_DETECTION_THRESHOLD)
-// {
-// visualizeSyncDetect(mSyncDetector.getDetectedPattern(), primaryScore, true);
-// }
-
- if(primaryScore > SYNC_DETECTION_THRESHOLD && optimizeFine(mSyncDetector.getDetectedPattern()))
- {
- //Update the sync mode monitor and message framer
- mSyncModeMonitor.detected(mSyncDetector.getDetectedPattern());
- mMessageFramer.syncDetected(mSyncDetector.getDetectedPattern());
- mSymbolsSinceLastSync = 0;
- }
- else
- {
- mFineSync = false;
- mSyncDetectorSecondary.reset();
- secondary = mBufferPointer + mSamplePoint - mSecondarySyncOffset;
- secondaryIntegral = (int)Math.floor(secondary);
- softSymbol = LinearInterpolator.calculate(mBuffer[secondaryIntegral], mBuffer[secondaryIntegral + 1], secondary - secondaryIntegral);
- mSyncDetectorSecondary.process(softSymbol);
- }
- }
- }
- }
- else
- {
- primaryScore = mSyncDetector.calculate();
- secondary = mBufferPointer + mSamplePoint - mSecondarySyncOffset;
- secondaryIntegral = (int)Math.floor(secondary);
- softSymbol = LinearInterpolator.calculate(mBuffer[secondaryIntegral], mBuffer[secondaryIntegral + 1], secondary - secondaryIntegral);
- secondaryScore = mSyncDetectorSecondary.processAndCalculate(softSymbol);
-
- if(primaryScore > SYNC_DETECTION_THRESHOLD && optimizeCoarse(mSyncDetector.getDetectedPattern(), 0))
- {
- mMessageFramer.syncDetected(mSyncDetector.getDetectedPattern());
- mFineSync = true;
- mSymbolsSinceLastSync = 0;
- }
- else if(secondaryScore > SYNC_DETECTION_THRESHOLD && optimizeCoarse(mSyncDetectorSecondary.getDetectedPattern(), -mSecondarySyncOffset))
- {
- mMessageFramer.syncDetected(mSyncDetectorSecondary.getDetectedPattern());
- mFineSync = true;
- mSymbolsSinceLastSync = 0;
- }
- }
-
- //Add another symbol's worth of samples to the counter
- mSamplePoint += mObservedSamplesPerSymbol;
- }
- }
- }
- }
-
- /**
- * Indicates if the samples in the sample buffer that contain a detected sync pattern have a standard deviation
- * that is lower than the expected sample to sample deviation for a modulated signal. False detects against
- * noise tend to have a standard deviation that is 2x the expected value.
- * @param offset to the sample representing the final symbol in the detected sync pattern.
- * @return true if the standard deviation is more than expected.
- */
- private boolean isNoisy(double offset)
- {
- StandardDeviation standardDeviation = new StandardDeviation();
- int start = (int)Math.floor(offset - (23 * mObservedSamplesPerSymbol));
- int end = (int)Math.ceil(offset);
- end = Math.min(end, mBuffer.length - 1);
-
- for(int i = start; i < end; i++)
- {
- standardDeviation.increment(mBuffer[i] - mBuffer[i + 1]);
- }
-
- return standardDeviation.getResult() > mNoiseStandardDeviationThreshold;
- }
-
- /**
- * Adjusts the symbol timing and symbol spacing to identify the best achievable sync correlation score and apply
- * those adjustments when the correlation score exceeds a positive sync detection threshold.
- * @param pattern that was detected
- * @param additionalOffset from current mBufferPointer and mSamplePoint. This can be zero offset for the primary
- * sync detector or an offset for the lagging sync detectors.
- * @return true if there is a positive sync detection.
- */
- private boolean optimizeCoarse(DMRSyncPattern pattern, double additionalOffset)
- {
- //Offset is the start of the first sample of the first symbol of the sync pattern calculated from the current
- //buffer pointer and sample point which should be the final sample of the final symbol of the detected sync.
- double offset = mBufferPointer + mSamplePoint + additionalOffset;
-
- //Reject any sync detections where the sample:sample standard deviation exceeds the noise threshold.
- if(isNoisy(offset))
- {
- return false;
- }
-
- //Find the optimal symbol timing
- double stepSize = mSamplesPerSymbol / 8.0; //Start at 1/8th of the samples per symbol
- double stepSizeMin = mOptimizeFineIncrement;
- double adjustment = 0.0;
- double adjustmentMax = mSamplesPerSymbol / 4.0;
- double candidate = offset;
-
- float scoreCenter = score(candidate, mObservedSamplesPerSymbol, pattern);
-
- candidate = offset - stepSize;
- float scoreLeft = score(candidate, mObservedSamplesPerSymbol, pattern);
-
- candidate = offset + stepSize;
- float scoreRight = score(candidate, mObservedSamplesPerSymbol, pattern);
-
- while(stepSize > stepSizeMin && Math.abs(adjustment) <= adjustmentMax)
- {
- if(scoreLeft > scoreRight && scoreLeft > scoreCenter)
- {
- adjustment -= stepSize;
- scoreRight = scoreCenter;
- scoreCenter = scoreLeft;
-
- candidate = offset + adjustment - stepSize;
- scoreLeft = score(candidate, mObservedSamplesPerSymbol, pattern);
- }
- else if(scoreRight > scoreLeft && scoreRight > scoreCenter)
- {
- adjustment += stepSize;
- scoreLeft = scoreCenter;
- scoreCenter = scoreRight;
-
- candidate = offset + adjustment + stepSize;
- scoreRight = score(candidate, mObservedSamplesPerSymbol, pattern);
- }
- else
- {
- stepSize *= 0.5f;
-
- if(stepSize > stepSizeMin)
- {
- candidate = offset + adjustment - stepSize;
- scoreLeft = score(candidate, mObservedSamplesPerSymbol, pattern);
-
- candidate = offset + adjustment + stepSize;
- scoreRight = score(candidate, mObservedSamplesPerSymbol, pattern);
- }
- }
- }
-
- //If we didn't find an optimal correlation score above the 95 threshold, return a false sync.
- if(scoreCenter < 95)
- {
- return false;
- }
-
- adjustment += additionalOffset;
- mSamplePoint += adjustment;
-
- while(mSamplePoint < 0)
- {
- mSamplePoint++;
- mBufferPointer--;
- }
-
- while(mSamplePoint > 1)
- {
- mSamplePoint--;
- mBufferPointer++;
- }
-
- boolean resample = !mEqualizerInitialized || (Math.abs(adjustment) > 0.25);
-
- updateEqualizer(pattern);
-// visualizeSyncDetect(pattern, scoreCenter, true);
-
- //If the equalizer was just initialized or the timing error adjustment is high enough, resample the symbols.
- // Otherwise, overwrite the captured sync pattern in the delay buffer with the detected sync pattern to
- // eliminate any sync bit errors.
- if(resample)
- {
- double resamplePointer = mBufferPointer + mSamplePoint;
- resamplePointer -= (89 * mObservedSamplesPerSymbol); //Start at 89 (+ 1 current = 90) symbols
- int integral;
-
- for(int x = 0; x < 66; x++)
- {
- integral = (int)Math.floor(resamplePointer);
-
- if(integral >= 0)
- {
- float resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[integral], mBuffer[integral + 1], resamplePointer - integral);
- mDibitDelayLine.insert(toSymbol(resampledSoftSymbol));
- }
- else
- {
- //This shouldn't happen since there's 2x dibits of padding on the front side, but just in case.
- mDibitDelayLine.insert(Dibit.D01_PLUS_3);
- }
-
- resamplePointer += mObservedSamplesPerSymbol;
- }
-
- //We don't need to resample the sync region ... just use the actual sync dibit values.
- for(Dibit dibit: pattern.toDibits())
- {
- mDibitDelayLine.insert(dibit);
- }
- }
- else
- {
- //Overwrite the most recent 24 dibits with the detected sync so there's no sync bit errors
- mDibitDelayLine.update(pattern.toDibits());
- }
-
- return true;
- }
-
- /**
- * Performs fine-grained symbol timing optimization of +/- .4% of the samples per symbol as needed.
- * @param pattern that was detected
- * @return true if detection score exceeds the optimized and equalized threshold for a quality sync detect.
- */
- private boolean optimizeFine(DMRSyncPattern pattern)
- {
- //Offset is the sample for the last symbol of the detected sync pattern.
- double offset = mBufferPointer + mSamplePoint;
- float currentScore = score(offset, mObservedSamplesPerSymbol, pattern);
- double candidate = offset - mOptimizeFineIncrement;
- float candidateScore = score(candidate, mObservedSamplesPerSymbol, pattern);
- boolean adjusted = false;
-
- if(candidateScore > currentScore)
- {
- mSamplePoint -= mOptimizeFineIncrement;
- adjusted = true;
- }
- else
- {
- candidate = offset + mOptimizeFineIncrement;
- candidateScore = score(candidate, mObservedSamplesPerSymbol, pattern);
-
- if(candidateScore > currentScore)
- {
- mSamplePoint += mOptimizeFineIncrement;
- adjusted = true;
- }
- }
-
- if(adjusted)
- {
- while(mSamplePoint < 0)
- {
- mSamplePoint++;
- mBufferPointer--;
- }
-
- while(mSamplePoint > 1)
- {
- mSamplePoint--;
- mBufferPointer++;
- }
- }
-
- if(candidateScore > SYNC_OPTIMIZED_THRESHOLD)
- {
- updateEqualizer(pattern);
- }
-
- return candidateScore > SYNC_EQUALIZED_THRESHOLD;
- }
-
- /**
- * Update the equalizer balance and gain when the sync pattern is detected in the sample buffer. This method
- * resamples the symbols and compares each soft symbol to the ideal symbol phase to develop average error
- * measurements for balance and gain. On initial sync detection, the equalizer settings are applied to the
- * samples in the buffer allowing the symbols to be resampled during coarse sync acquisition.
- * @param pattern that was detected.
- */
- private void updateEqualizer(DMRSyncPattern pattern)
- {
- float[] symbols = pattern.toSymbols();
- double resampleStart = mBufferPointer + mSamplePoint;
- int resampleStartIntegral = (int)Math.floor(resampleStart);
- float resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[resampleStartIntegral],
- mBuffer[resampleStartIntegral + 1], resampleStart - resampleStartIntegral);
- float balanceAccumulator = resampledSoftSymbol - symbols[23];
- float gainAccumulator = Math.abs(symbols[23]) - Math.abs(resampledSoftSymbol);
-
- resampleStart -= (23 * mObservedSamplesPerSymbol); //Start at 89 (+ 1 current = 90) symbols
- resampleStartIntegral = (int)Math.floor(resampleStart);
-
- for(int x = 0; x < 23; x++)
- {
- if(resampleStartIntegral >= 0)
- {
- resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[resampleStartIntegral],
- mBuffer[resampleStartIntegral + 1], resampleStart - resampleStartIntegral);
- balanceAccumulator += resampledSoftSymbol - symbols[x];
- gainAccumulator += Math.abs(symbols[x]) - Math.abs(resampledSoftSymbol);
- }
-
- resampleStart += mObservedSamplesPerSymbol;
- resampleStartIntegral = (int)Math.floor(resampleStart);
- }
-
- //Average the accumulated error over 24 sync symbols
- balanceAccumulator /= -24.0f;
- gainAccumulator /= (24.0f * Dibit.D01_PLUS_3.getIdealPhase());
-
- if(mEqualizerInitialized)
- {
- //Limit equalizer adjustments at each sync after the initial equalizer setup.
- mEqualizerBalance += (balanceAccumulator * EQUALIZER_LOOP_GAIN);
- mEqualizerGain += (gainAccumulator * EQUALIZER_LOOP_GAIN);
- }
- else
- {
- mEqualizerBalance += balanceAccumulator;
- mEqualizerGain += gainAccumulator;
- }
-
- //Constrain balance to +/- PI/4
- mEqualizerBalance = Math.min(mEqualizerBalance, MAXIMUM_EQUALIZER_BALANCE);
- mEqualizerBalance = Math.max(mEqualizerBalance, -MAXIMUM_EQUALIZER_BALANCE);
-
- mFeedbackDecoder.processPLLError(mEqualizerBalance);
-
- //Constrain gain between 1.0f and 1.35f
- mEqualizerGain = Math.min(mEqualizerGain, MAXIMUM_EQUALIZER_GAIN);
- mEqualizerGain = Math.max(mEqualizerGain, 1.0f);
-
- if(!mEqualizerInitialized)
- {
- //Apply the initial gain settings to the samples in the buffer so that the symbols can be resampled.
- for(int x = 0; x < mBufferPointer; x++)
- {
- mBuffer[x] = (mBuffer[x] + mEqualizerBalance) * mEqualizerGain;
- }
-
- mEqualizerInitialized = true;
- }
- }
-
- /**
- * Debug method to visualize the contents of the sample buffer.
- */
- public void visualizeBufferContents()
- {
- //This will block until the viewer is constructed and showing.
- if(mSyncResultsViewer == null)
- {
- mSyncResultsViewer = new SyncResultsViewer();
- }
-
- int offset = 3;
- int length = (int)Math.ceil(mObservedSamplesPerSymbol * 90) + (2 * offset);
- int end = mBufferPointer + offset;
- int start = end - length;
- float[] symbols = new float[90];
- float[] samples = Arrays.copyOfRange(mBuffer, start, end);
-
- float[] intervals = new float[90];
-
- int adjust = mBufferPointer - length + offset;
- double pointer = mBufferPointer + mSamplePoint - adjust;
-
- for(int x = 89; x >= 0; x--)
- {
- intervals[x] = (float)pointer;
- pointer -= mObservedSamplesPerSymbol;
- }
-
- float[] sync = new float[90];
- Arrays.fill(sync, -1.57f); //Cutoff between -1 & -1 to show symbol timings
-
- CountDownLatch countDownLatch = new CountDownLatch(1);
- mSyncResultsViewer.receive(symbols, sync, samples, intervals, mEqualizerBalance, mEqualizerGain,
- "BUFFER CONTENTS", countDownLatch);
-
- try
- {
- countDownLatch.await();
- }
- catch(InterruptedException e)
- {
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Debug method to visualize the contents of the sync detection for the samples and symbols and symbol timing.
- * @param pattern that was detected.
- * @param score from the sync detector
- * @param primary sync detector (true) or secondary (false)
- */
- public void visualizeSyncDetect(DMRSyncPattern pattern, float score, boolean primary)
- {
- //This will block until the viewer is constructed and showing.
- if(mSyncResultsViewer == null)
- {
- mSyncResultsViewer = new SyncResultsViewer();
- }
-
- int offset = 3;
- int length = (int)Math.ceil(mObservedSamplesPerSymbol * 23) + (2 * offset);
- int end = mBufferPointer + offset;
- int start = end - length;
- float[] symbols = new float[24];
- float[] samples = Arrays.copyOfRange(mBuffer, start, end);
-
- float[] intervals = new float[24];
-
- int adjust = mBufferPointer - length + offset;
- double pointer = mBufferPointer + mSamplePoint - adjust;
-
- double symbolPointer = mBufferPointer + mSamplePoint - (mObservedSamplesPerSymbol * 23);
- int symbolIntegral = (int)Math.floor(symbolPointer);
- double mu = symbolPointer - symbolIntegral;
-
- for(int x = 0; x < 24; x++)
- {
- symbols[x] = LinearInterpolator.calculate(mBuffer[symbolIntegral], mBuffer[symbolIntegral + 1], mu);
- symbolPointer += mObservedSamplesPerSymbol;
- symbolIntegral = (int)Math.floor(symbolPointer);
- mu = symbolPointer - symbolIntegral;
- }
-
- for(int x = 23; x >= 0; x--)
- {
- intervals[x] = (float)pointer;
- pointer -= mObservedSamplesPerSymbol;
- }
-
- CountDownLatch countDownLatch = new CountDownLatch(1);
- mSyncResultsViewer.receive(symbols, pattern.toSymbols(), samples, intervals, mEqualizerBalance, mEqualizerGain,
- pattern + " Score: " + score + (primary ? " PRIMARY" : " SECONDARY"), countDownLatch);
-
- try
- {
- countDownLatch.await();
- }
- catch(InterruptedException e)
- {
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Calculates the sync correlation score for sync pattern ending at the specified offset and samples per symbol interval.
- * @param offset to the final symbol in the soft symbol buffer
- * @param samplesPerSymbol spacing to test for.
- * @param pattern to correlate against.
- * @return correlation score.
- */
- public float score(double offset, double samplesPerSymbol, DMRSyncPattern pattern)
- {
- float[] symbols = pattern.toSymbols();
- int maxPointer = mBuffer.length - 1;
- float softSymbol;
-
- double pointer = offset - (samplesPerSymbol * 23.0);
- int bufferPointer = (int)Math.floor(pointer);
- double fractional = pointer - bufferPointer;
-
- float score = 0;
-
- for(int x = 0; x < 24; x++)
- {
- if(bufferPointer < maxPointer)
- {
- softSymbol = LinearInterpolator.calculate(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], fractional);
- }
- else
- {
- softSymbol = 0.0f;
- }
-
- score += softSymbol * symbols[x];
- pointer += samplesPerSymbol;
- bufferPointer = (int)Math.floor(pointer);
- fractional = pointer - bufferPointer;
- }
-
- return score;
- }
-
- /**
- * Sets or updates the samples per symbol
- * @param samplesPerSymbol to apply.
- */
- public void setSamplesPerSymbol(float samplesPerSymbol)
- {
- mSamplesPerSymbol = samplesPerSymbol;
- float maxSamplesPerSymbol = samplesPerSymbol * (1.0f + SAMPLES_PER_SYMBOL_ALLOWABLE_DEVIATION);
- mOptimizeFineIncrement = samplesPerSymbol * .004f; //Adjust at .4%
- //Protected region for score() method to avoid IndexOutOfBounds = Max Adjustment + Max Step Size for Max SPS
- mBufferInterpolatorReservedRegion = (int)Math.ceil((maxSamplesPerSymbol / 2.0f) + (maxSamplesPerSymbol / 10.0f));
- mObservedSamplesPerSymbol = mSamplesPerSymbol;
- mNoiseStandardDeviationThreshold = Dibit.D01_PLUS_3.getIdealPhase() * 2 / mSamplesPerSymbol * 1.2; //120% of optimal
- mSamplePoint = mSamplesPerSymbol;
- mSecondarySyncOffset = mSamplesPerSymbol / 2.0;
- mBufferWorkspaceLength = (int)Math.ceil(BUFFER_WORKSPACE_LENGTH_DIBITS * mSamplesPerSymbol);
- int bufferLength = (int)(Math.ceil(BUFFER_LENGTH_DIBITS * mSamplesPerSymbol));
- mBuffer = new float[bufferLength];
- mBufferLoadPointer = (int)Math.ceil(BUFFER_PROTECTED_REGION_DIBITS * mSamplesPerSymbol);
- mBufferPointer = mBufferLoadPointer;
- }
-
- /**
- * Decodes the sample value to determine the correct QPSK quadrant and maps the value to a Dibit symbol.
- * @param sample in radians.
- * @return symbol decision.
- */
- private static Dibit toSymbol(float sample)
- {
- if(sample > 0)
- {
- return sample > SYMBOL_QUADRANT_BOUNDARY ? Dibit.D01_PLUS_3 : Dibit.D00_PLUS_1;
- }
- else
- {
- return sample < -SYMBOL_QUADRANT_BOUNDARY ? Dibit.D11_MINUS_3 : Dibit.D10_MINUS_1;
- }
- }
-}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSymbolProcessor.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSymbolProcessor.java
new file mode 100644
index 00000000..d65fa660
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSymbolProcessor.java
@@ -0,0 +1,208 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr;
+
+import io.github.dsheirer.dsp.c4fm.C4FMSymbolProcessor;
+import io.github.dsheirer.dsp.c4fm.ISoftC4FMSyncDetector;
+import io.github.dsheirer.module.decode.FeedbackDecoder;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSoftSyncDetector;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSoftSyncDetectorFactory;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSyncDetectMode;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSyncModeMonitor;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSyncPattern;
+
+/**
+ * DMR implementation of the C4FM symbol processor.
+ *
+ * DMR employs multiple sync patterns (BASE STATION, MOBILE STATION and DIRECT MODE, each with DATA and VOICE variants)
+ * and the sync detectors correlate against all active sync patterns, reporting the best scoring pattern. The sync mode
+ * monitor processes the sync detections to identify the dominant sync mode for the channel and then disables
+ * processing of the two unused sync modes for the remainder of the channel.
+ *
+ * On each sync detection, the detected sync pattern is passed to the message framer which maintains a delay line to
+ * retain the burst dibits from the CACH through the sync pattern so that the full burst can be framed.
+ *
+ * DMR sync patterns are made up of equal quantities of +3 and -3 symbols which supports the default (mean) balance
+ * measurement approach of the parent class.
+ */
+public class DMRSymbolProcessor extends C4FMSymbolProcessor
+{
+ private static final int BURST_DIBIT_LENGTH = 144;
+ private static final int SYNC_DIBIT_LENGTH = 24;
+ //Maximum sync interval for data bursts is two bursts for single timeslot (ie mobile/direct mode) data.
+ private static final int MAX_DATA_SYNC_INTERVAL = BURST_DIBIT_LENGTH * 2 + SYNC_DIBIT_LENGTH;
+ //Maximum sync interval for a voice super frame is 6x bursts per timeslot for single timeslot (ie direct mode) voice.
+ private static final int MAX_VOICE_SYNC_INTERVAL = BURST_DIBIT_LENGTH * 12 + SYNC_DIBIT_LENGTH;
+ //Training period is limited to the remainder of the burst that follows the sync since the next burst can be
+ //empty (ie noise) for mobile station and direct mode channels. Testing: longer periods (120-144) gained less
+ //than 0.2% on base station recordings, which doesn't justify the risk of training on empty timeslots.
+ private static final int EQUALIZER_TRAINING_PERIOD_SYMBOL_COUNT = 54;
+ private static final int EQUALIZER_SKIP_TRAINING_FRAME_COUNT = 10;
+ private static final int EQUALIZER_SYMBOL_LENGTH = 7;
+ private static final int SYMBOL_RATE = 4800;
+ private static final double OUTER_SYMBOL_DEVIATION = 1944.0; //Hertz
+ //Sync thresholds. Testing against DMR recordings and injected noise: lowering the optimized and equalized
+ //thresholds from 100 to 70 (60 - 70 perform the same) increased valid bursts 5% on multipath recordings and
+ //7 - 27% with injected noise. Lowering the detection threshold below 80 floods the optimizer with false syncs.
+ private static final float SYNC_THRESHOLD_DETECTED = 80;
+ private static final float SYNC_THRESHOLD_OPTIMIZED = 70;
+ private static final float SYNC_THRESHOLD_EQUALIZED = 70;
+ //Once synchronized, sync detections are only allowed within this many symbols of a burst boundary.
+ private static final int SYNC_WINDOW_SYMBOLS = 2;
+ private final DMRSyncModeMonitor mSyncModeMonitor = new DMRSyncModeMonitor();
+ private final DMRMessageFramer mMessageFramer;
+
+ /**
+ * Constructs an instance
+ *
+ * @param feedbackDecoder parent decoder
+ * @param messageFramer to receive symbol decisions (dibits) and sync notifications.
+ */
+ public DMRSymbolProcessor(FeedbackDecoder feedbackDecoder, DMRMessageFramer messageFramer)
+ {
+ this(feedbackDecoder, messageFramer, DMRSoftSyncDetectorFactory.getDetector(),
+ DMRSoftSyncDetectorFactory.getDetector());
+ }
+
+ /**
+ * Constructs an instance
+ *
+ * @param feedbackDecoder parent decoder
+ * @param messageFramer to receive symbol decisions (dibits) and sync notifications.
+ * @param primary sync detector
+ * @param lagging sync detector
+ */
+ private DMRSymbolProcessor(FeedbackDecoder feedbackDecoder, DMRMessageFramer messageFramer,
+ DMRSoftSyncDetector primary, DMRSoftSyncDetector lagging)
+ {
+ super(feedbackDecoder, messageFramer, primary, lagging);
+ mMessageFramer = messageFramer;
+ mSyncModeMonitor.add(primary);
+ mSyncModeMonitor.add(lagging);
+ }
+
+ /**
+ * Sets base station sync detection mode. This disables detection for MOBILE and DIRECT sync patterns. This can
+ * be used for traffic channels that are known to be part of a base station.
+ * @param enabled true for base mode only, false otherwise.
+ */
+ public void setBaseStationMode(boolean enabled)
+ {
+ mSyncModeMonitor.setMode(enabled ? DMRSyncDetectMode.BASE_ONLY : DMRSyncDetectMode.AUTOMATIC);
+ }
+
+ /**
+ * Captures the detected sync pattern to update the sync mode monitor and the message framer.
+ * @param syncDetector that triggered the sync detection.
+ */
+ @Override
+ protected void syncDetected(ISoftC4FMSyncDetector syncDetector)
+ {
+ if(syncDetector instanceof DMRSoftSyncDetector dmrSyncDetector)
+ {
+ DMRSyncPattern pattern = dmrSyncDetector.getDetectedPattern();
+ mSyncModeMonitor.detected(pattern);
+ mMessageFramer.setSyncPattern(pattern);
+ }
+ }
+
+ /**
+ * Once synchronized with at least two consecutive sync detections, only allows sync detections within a small
+ * window around each expected burst boundary (multiples of the burst length) to prevent false sync detections
+ * from burst payload (ie voice) symbols that would otherwise cause the framer to abandon the current burst.
+ * @param symbolsSinceLastSync count of symbols since the final symbol of the previous sync detection.
+ * @return true if a sync detection is allowed.
+ */
+ @Override
+ protected boolean isSyncDetectionAllowed(int symbolsSinceLastSync)
+ {
+ if(!mSynchronized || getConsecutiveSyncPeriods() < 2)
+ {
+ return true;
+ }
+
+ int offset = symbolsSinceLastSync % BURST_DIBIT_LENGTH;
+ return Math.min(offset, BURST_DIBIT_LENGTH - offset) <= SYNC_WINDOW_SYMBOLS;
+ }
+
+ @Override
+ public boolean isDynamicBalanceTrackingRequired()
+ {
+ return true;
+ }
+
+ /**
+ * Maximum dibit interval between sync detections, which is longer while the framer is assembling a voice super
+ * frame where only the first burst of each super frame contains a sync pattern.
+ */
+ @Override
+ public int getMaxMessageDibitLength()
+ {
+ return mMessageFramer.isVoiceSuperFrame() ? MAX_VOICE_SYNC_INTERVAL : MAX_DATA_SYNC_INTERVAL;
+ }
+
+ @Override
+ public int getEqualizerTrainingPeriodDurationSymbolCount()
+ {
+ return EQUALIZER_TRAINING_PERIOD_SYMBOL_COUNT;
+ }
+
+ @Override
+ public int getEqualizerSymbolLength()
+ {
+ return EQUALIZER_SYMBOL_LENGTH;
+ }
+
+ @Override
+ public int getEqualizerSkipTrainingFrameCount()
+ {
+ return EQUALIZER_SKIP_TRAINING_FRAME_COUNT;
+ }
+
+ @Override
+ public int getSymbolRate()
+ {
+ return SYMBOL_RATE;
+ }
+
+ @Override
+ public double getOuterSymbolDeviation()
+ {
+ return OUTER_SYMBOL_DEVIATION;
+ }
+
+ @Override
+ public float getSyncThresholdDetected()
+ {
+ return SYNC_THRESHOLD_DETECTED;
+ }
+
+ @Override
+ public float getSyncThresholdOptimized()
+ {
+ return SYNC_THRESHOLD_OPTIMIZED;
+ }
+
+ @Override
+ public float getSyncThresholdEqualized()
+ {
+ return SYNC_THRESHOLD_EQUALIZED;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
index ff093199..890efcf4 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77.java
@@ -22,6 +22,8 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming16;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
+import io.github.dsheirer.edac.soft.SoftProductCodeDecoder;
/**
* Block product turbo code (BPTC) for decoding Full Link Control messages reassembled from the 32-bit payload fragments
@@ -29,6 +31,41 @@ import io.github.dsheirer.edac.Hamming16;
*/
public class BPTC_128_77 extends BPTCBase
{
+ private static final int LENGTH = 128;
+ private static final int ROW_COUNT = 8;
+ private static final int COLUMN_COUNT = 16;
+ //Maximum correction cost (sum of the normalized channel soft bit magnitudes for the corrected bits) for a soft
+ //decision result. The embedded link control is only protected by a 5-bit checksum, so soft decisions that
+ //require correcting high reliability bits are rejected to avoid false decodes. Noise injection testing: all
+ //soft decisions with a cost up to 3.0 were correct, whereas most of those with a higher cost were false decodes.
+ private static final float SOFT_MAX_CORRECTION_COST = 3.0f;
+ //Maximum corrected bit count for a hard decision result. Hard decisions that correct more bits are treated as
+ //uncorrectable to avoid false decodes, given the weak 5-bit checksum. Noise injection testing: all hard
+ //decisions with more than 6 corrected bits were false decodes.
+ private static final int HARD_MAX_CORRECTED_BITS = 6;
+ //Soft decision decoder for the Hamming(16,11,4) rows and even parity columns
+ private static final SoftProductCodeDecoder SOFT_DECODER = new SoftProductCodeDecoder(
+ SoftBlockCode.fromCodewordTest(COLUMN_COUNT, word -> Hamming16.getSyndrome(toMessage(word), 0) == 0),
+ ROW_COUNT, SOFT_MAX_CORRECTION_COST);
+
+ /**
+ * Creates a binary message from the word where bit i of the word is message bit i.
+ */
+ private static BinaryMessage toMessage(int word)
+ {
+ BinaryMessage message = new BinaryMessage(COLUMN_COUNT);
+
+ for(int i = 0; i < COLUMN_COUNT; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
public BPTC_128_77()
{
super(new Hamming16(), 16, 8);
@@ -49,10 +86,38 @@ public class BPTC_128_77 extends BPTCBase
* @return decoded message.
*/
public CorrectedBinaryMessage extract(BinaryMessage message)
+ {
+ return extract(message, null);
+ }
+
+ /**
+ * Performs error detection and correction and extracts the payload from the BPTC encoded message. Hard decision
+ * results that exceed the maximum corrected bit count are treated as uncorrectable. When the hard decision
+ * decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding.
+ * @param message with BPTC encoding.
+ * @param softBits for the 128 (interleaved) bits of the message where a positive value favors a 1 bit, or null.
+ * @return decoded message.
+ */
+ public CorrectedBinaryMessage extract(BinaryMessage message, float[] softBits)
{
CorrectedBinaryMessage deinterleaved = deinterleave(message);
correct(deinterleaved);
+ if(deinterleaved.getCorrectedBitCount() > HARD_MAX_CORRECTED_BITS)
+ {
+ deinterleaved.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
+ }
+
+ if(softBits != null && softBits.length == LENGTH && deinterleaved.getCorrectedBitCount() < 0)
+ {
+ CorrectedBinaryMessage softDecoded = decodeSoft(deinterleave(softBits));
+
+ if(softDecoded != null)
+ {
+ deinterleaved = softDecoded;
+ }
+ }
+
CorrectedBinaryMessage extractedMessage = new CorrectedBinaryMessage(77);
int pointer = 0;
@@ -80,6 +145,38 @@ public class BPTC_128_77 extends BPTCBase
return extractedMessage;
}
+ /**
+ * Deinterleaves the transmitted soft bits.
+ * @param interleaved soft bits
+ * @return deinterleaved soft bits.
+ */
+ public static float[] deinterleave(float[] interleaved)
+ {
+ float[] deinterleaved = new float[LENGTH];
+ deinterleaved[127] = interleaved[127];
+
+ for(int i = 0; i < 127; i++)
+ {
+ deinterleaved[i] = interleaved[(i * 8) % 127];
+ }
+
+ return deinterleaved;
+ }
+
+ /**
+ * Iterative soft decision decoding of the de-interleaved BPTC(128,77) soft bits. Alternately decodes the
+ * Hamming(16,11,4) rows using Chase-Pyndiah decoding and the even parity columns using min-sum decoding,
+ * exchanging extrinsic soft information, until the decisions form a valid product codeword. Rejects decisions
+ * where the correction cost exceeds the maximum.
+ * @param channel de-interleaved soft bits where a positive value favors a 1 bit.
+ * @return decoded message with the corrected bit count set, or null if decoding doesn't converge or the correction
+ * cost exceeds the maximum.
+ */
+ public CorrectedBinaryMessage decodeSoft(float[] channel)
+ {
+ return SOFT_DECODER.decode(channel);
+ }
+
/**
* Deinterleaves the transmitted message.
* @param interleaved message
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
index 3fcc011a..61e3a77a 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96.java
@@ -25,6 +25,7 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming13;
import io.github.dsheirer.edac.Hamming15;
import io.github.dsheirer.edac.IHamming;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -42,6 +43,21 @@ public class BPTC_196_96
public static final int CHECKSUM_COLUMN_COUNT = 4;
public static final int MESSAGE_START_INDEX = 4;
public static final int MAXIMUM_RECURSION_TURBO_DEPTH = 20;
+ private static final int ROW_COUNT = 13;
+ private static final int ROW_LENGTH = 15;
+ private static final int COLUMN_LENGTH = 13;
+
+ //Soft decision (Chase-Pyndiah) iterative decoding settings. Each half iteration decodes either all rows or all
+ //columns, starting with the rows. Alpha scales the extrinsic information fed to the next half iteration and beta
+ //is the reliability assigned to bits without a competing candidate codeword.
+ private static final int CHASE_LEAST_RELIABLE_BITS = 4;
+ private static final float[] SOFT_ALPHA = new float[]{0.0f, 0.2f, 0.3f, 0.5f, 0.7f, 0.9f, 1.0f, 1.0f};
+ private static final float[] SOFT_BETA = new float[]{0.2f, 0.4f, 0.6f, 0.8f, 1.0f, 1.0f, 1.0f, 1.0f};
+ private static final int[] COLUMN_WORD_INDEXES = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
+ private static final SoftBlockCode ROW_CODE = SoftBlockCode.fromCodewordTest(ROW_LENGTH,
+ word -> Hamming15.getSyndrome(toMessage(word, ROW_LENGTH), 0) == 0);
+ private static final SoftBlockCode COLUMN_CODE = SoftBlockCode.fromCodewordTest(COLUMN_LENGTH,
+ word -> Hamming13.getSyndrome(toMessage(word, COLUMN_LENGTH), COLUMN_WORD_INDEXES) == 0);
/**
* DMR De-interleaving indices
@@ -76,6 +92,40 @@ public class BPTC_196_96
{14, 29, 44, 59, 74, 89, 104, 119, 134, 149, 164, 179, 194},
{15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195}};
+ /**
+ * DMR Row Indices
+ */
+ public static final int[][] ROW_INDEXES = new int[ROW_COUNT][ROW_LENGTH];
+
+ static
+ {
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ for(int x = 0; x < ROW_LENGTH; x++)
+ {
+ ROW_INDEXES[row][x] = getIndex(x, row);
+ }
+ }
+ }
+
+ /**
+ * Creates a binary message from the word where bit i of the word is message bit i.
+ */
+ private static BinaryMessage toMessage(int word, int length)
+ {
+ BinaryMessage message = new BinaryMessage(length);
+
+ for(int i = 0; i < length; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
/**
* Performs de-interleave, error detect and correct and extracts a 96-bit message from the DMR BPTC(196,96)
* protected message
@@ -83,6 +133,19 @@ public class BPTC_196_96
* @return error corrected and extracted 96-bit message
*/
public static CorrectedBinaryMessage extract(CorrectedBinaryMessage original)
+ {
+ return extract(original, null);
+ }
+
+ /**
+ * Performs de-interleave, error detect and correct and extracts a 96-bit message from the DMR BPTC(196,96)
+ * protected message. When the hard decision decoder can't correct the message and soft bits are provided,
+ * applies iterative soft decision decoding.
+ * @param original uncorrected BPTC(196,96) message
+ * @param softBits for the 196 (interleaved) bits of the original message, or null.
+ * @return error corrected and extracted 96-bit message
+ */
+ public static CorrectedBinaryMessage extract(CorrectedBinaryMessage original, float[] softBits)
{
//De-Interleave
CorrectedBinaryMessage message = deinterleave(original);
@@ -91,6 +154,43 @@ public class BPTC_196_96
boolean pursueShadows = true;
correct(message, pursueShadows);
+ if(softBits != null && softBits.length == BPTC_LENGTH && message.getCorrectedBitCount() < 0)
+ {
+ CorrectedBinaryMessage softDecoded = decodeSoft(deinterleave(softBits));
+
+ if(softDecoded != null)
+ {
+ message = softDecoded;
+ }
+ }
+
+ return extractPayload(message);
+ }
+
+ /**
+ * Applies soft decision decoding to the soft bits and extracts the 96-bit message. Can be used when a hard
+ * decision result fails a subsequent message integrity (ie CRC) check.
+ * @param softBits for the 196 (interleaved) bits of the message where a positive value favors a 1 bit.
+ * @return error corrected and extracted 96-bit message, or null if soft decision decoding doesn't converge.
+ */
+ public static CorrectedBinaryMessage extractSoft(float[] softBits)
+ {
+ if(softBits == null || softBits.length != BPTC_LENGTH)
+ {
+ return null;
+ }
+
+ CorrectedBinaryMessage decoded = decodeSoft(deinterleave(softBits));
+ return decoded != null ? extractPayload(decoded) : null;
+ }
+
+ /**
+ * Extracts the 96-bit message and the 3 reserved bits from the corrected (de-interleaved) 196-bit message.
+ * @param message corrected and de-interleaved.
+ * @return extracted message with the corrected bit count transferred.
+ */
+ private static CorrectedBinaryMessage extractPayload(CorrectedBinaryMessage message)
+ {
//Extract the 96-bit message from the corrected 196 bits
CorrectedBinaryMessage extracted = new CorrectedBinaryMessage(EXTRACTED_LENGTH);
@@ -130,6 +230,174 @@ public class BPTC_196_96
return extracted;
}
+ /**
+ * De-interleaves the transmitted soft bits of a DMR BPTC(196,96) protected message
+ * @param softBits that contains interleaved (transmitted) soft bits
+ * @return de-interleaved soft bits
+ */
+ private static float[] deinterleave(float[] softBits)
+ {
+ float[] deinterleaved = new float[BPTC_LENGTH];
+
+ for(int x = 0; x < BPTC_DEINTERLEAVE.length; x++)
+ {
+ deinterleaved[x] = softBits[BPTC_DEINTERLEAVE[x]];
+ }
+
+ return deinterleaved;
+ }
+
+ /**
+ * Iterative soft decision (Chase-Pyndiah turbo product) decoding of the de-interleaved BPTC(196,96) soft bits.
+ * Alternately decodes the Hamming(15,11) rows and Hamming(13,9) columns, exchanging extrinsic soft information,
+ * until the decisions form a valid product codeword.
+ * @param channel de-interleaved soft bits where a positive value favors a 1 bit.
+ * @return decoded message with the corrected bit count set, or null if decoding doesn't converge.
+ */
+ public static CorrectedBinaryMessage decodeSoft(float[] channel)
+ {
+ //Normalize the channel soft bits to a mean magnitude of 1 for the alpha and beta settings
+ float sum = 0.0f;
+
+ for(int i = 1; i < BPTC_LENGTH; i++)
+ {
+ sum += Math.abs(channel[i]);
+ }
+
+ if(sum <= 0.0f)
+ {
+ return null;
+ }
+
+ float scale = (BPTC_LENGTH - 1) / sum;
+ float[] received = new float[BPTC_LENGTH];
+
+ for(int i = 0; i < BPTC_LENGTH; i++)
+ {
+ received[i] = channel[i] * scale;
+ }
+
+ float[] extrinsic = new float[BPTC_LENGTH];
+ float[] input = new float[BPTC_LENGTH];
+ float[] output = new float[BPTC_LENGTH];
+ int[] rowWords = new int[ROW_COUNT];
+ int[] columnWords = new int[COLUMN_COUNT];
+
+ for(int halfIteration = 0; halfIteration < SOFT_ALPHA.length; halfIteration++)
+ {
+ float alpha = SOFT_ALPHA[halfIteration];
+ float beta = SOFT_BETA[halfIteration];
+
+ for(int i = 0; i < BPTC_LENGTH; i++)
+ {
+ input[i] = received[i] + alpha * extrinsic[i];
+ }
+
+ boolean decodeRows = halfIteration % 2 == 0;
+ boolean allDecoded = true;
+
+ if(decodeRows)
+ {
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ rowWords[row] = ROW_CODE.decodeChase(input, ROW_INDEXES[row], output, CHASE_LEAST_RELIABLE_BITS, beta);
+ allDecoded &= rowWords[row] >= 0;
+ }
+ }
+ else
+ {
+ for(int column = 0; column < COLUMN_COUNT; column++)
+ {
+ columnWords[column] = COLUMN_CODE.decodeChase(input, COLUMN_INDEXES[column], output,
+ CHASE_LEAST_RELIABLE_BITS, beta);
+ allDecoded &= columnWords[column] >= 0;
+ }
+ }
+
+ //Check for a valid product codeword from the decisions of this half iteration
+ if(allDecoded)
+ {
+ CorrectedBinaryMessage decoded = new CorrectedBinaryMessage(BPTC_LENGTH);
+
+ if(received[0] > 0)
+ {
+ decoded.set(0);
+ }
+
+ int[][] decodedIndexes = decodeRows ? ROW_INDEXES : COLUMN_INDEXES;
+ int[] decodedWords = decodeRows ? rowWords : columnWords;
+
+ for(int w = 0; w < decodedWords.length; w++)
+ {
+ for(int i = 0; i < decodedIndexes[w].length; i++)
+ {
+ if((decodedWords[w] & (1 << i)) != 0)
+ {
+ decoded.set(decodedIndexes[w][i]);
+ }
+ }
+ }
+
+ if(isValidProductCodeword(decoded))
+ {
+ int corrected = 0;
+
+ for(int i = 0; i < BPTC_LENGTH; i++)
+ {
+ if(decoded.get(i) != (received[i] > 0))
+ {
+ corrected++;
+ }
+ }
+
+ decoded.setCorrectedBitCount(corrected);
+ return decoded;
+ }
+ }
+
+ //Normalize the extrinsic information to a mean magnitude of 1 for the next half iteration
+ float extrinsicSum = 0.0f;
+
+ for(int i = 1; i < BPTC_LENGTH; i++)
+ {
+ extrinsicSum += Math.abs(output[i]);
+ }
+
+ float extrinsicScale = extrinsicSum > 0.0f ? (BPTC_LENGTH - 1) / extrinsicSum : 0.0f;
+
+ for(int i = 0; i < BPTC_LENGTH; i++)
+ {
+ extrinsic[i] = output[i] * extrinsicScale;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Indicates if all rows and columns of the de-interleaved message pass the Hamming checks.
+ */
+ private static boolean isValidProductCodeword(CorrectedBinaryMessage message)
+ {
+ for(int row = 0; row < ROW_COUNT; row++)
+ {
+ if(!isRowCorrect(row, message))
+ {
+ return false;
+ }
+ }
+
+ for(int column = 0; column < COLUMN_COUNT; column++)
+ {
+ if(!isColumnCorrect(column, message))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* De-interleaves the transmitted bits of a DMR BPTC(196,96) protected message
* @param message that contains interleaved (transmitted) message
@@ -311,11 +579,36 @@ public class BPTC_196_96
* Performs turbo decode of the message by testing multiple paths across rows and columns to identify paths
* that resolve errors validated by the Hamming checks. Can also detect and often correct shadowing where the bit
* errors occur in a 2x2, 2x3, 3x2 or 3x3 column/row grid and can't normally be corrected by the Hamming code.
+ * When corrected, the message corrected bit count is set to the number of bits that differ from the received
+ * message.
+ *
* @param message to correct.
* @param pursueShadows to test intersection flipping for double/triple shadows.
* @return true if the message was corrected, false otherwise.
*/
public static boolean correct(CorrectedBinaryMessage message, boolean pursueShadows)
+ {
+ CorrectedBinaryMessage received = new CorrectedBinaryMessage(message);
+ boolean corrected = correctTurbo(message, pursueShadows);
+
+ if(corrected)
+ {
+ //The turbo decoder's incremental accounting can count a bit more than once, so measure the corrections
+ received.xor(message);
+ message.setCorrectedBitCount(received.cardinality());
+ }
+
+ return corrected;
+ }
+
+ /**
+ * Turbo decode implementation for the correct() method. Note: the corrected bit count set by this method is
+ * not accurate and is replaced by the correct() method.
+ * @param message to correct.
+ * @param pursueShadows to test intersection flipping for double/triple shadows.
+ * @return true if the message was corrected, false otherwise.
+ */
+ private static boolean correctTurbo(CorrectedBinaryMessage message, boolean pursueShadows)
{
List columnErrors = getColumnErrors(message);
List rowErrors = getRowErrors(message);
@@ -413,7 +706,7 @@ public class BPTC_196_96
//Set pursue shadows to false so we don't recursively chase shadows
pursueShadows = false;
- if(correct(message, pursueShadows))
+ if(correctTurbo(message, pursueShadows))
{
columns.clear();
rows.clear();
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
index b04ac3cf..1a27c144 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_68_36.java
@@ -22,12 +22,45 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming17;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
+import io.github.dsheirer.edac.soft.SoftProductCodeDecoder;
/**
* Block product turbo code (BPTC) for decoding Short Link Control reassembled from CACH fragments.
*/
public class BPTC_68_36 extends BPTCBase
{
+ private static final int LENGTH = 68;
+ private static final int ROW_COUNT = 4;
+ private static final int COLUMN_COUNT = 17;
+ //Maximum correction cost (sum of the normalized channel soft bit magnitudes for the corrected bits) for a soft
+ //decision result. No limit is applied since the CRC-8 check rejects most false decodes. Noise injection testing:
+ //754 correct and 2 false soft decisions, where the false decisions had costs within the range of the correct
+ //decisions, so a cost limit would reject far more correct decisions than false decisions.
+ private static final float SOFT_MAX_CORRECTION_COST = Float.MAX_VALUE;
+ //Soft decision decoder for the Hamming(17,12) rows and even parity columns
+ private static final SoftProductCodeDecoder SOFT_DECODER = new SoftProductCodeDecoder(
+ SoftBlockCode.fromCodewordTest(COLUMN_COUNT, word -> Hamming17.getSyndrome(toMessage(word), 0) == 0),
+ ROW_COUNT, SOFT_MAX_CORRECTION_COST);
+
+ /**
+ * Creates a binary message from the word where bit i of the word is message bit i.
+ */
+ private static BinaryMessage toMessage(int word)
+ {
+ BinaryMessage message = new BinaryMessage(COLUMN_COUNT);
+
+ for(int i = 0; i < COLUMN_COUNT; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
public BPTC_68_36()
{
super(new Hamming17(), 17, 4);
@@ -42,7 +75,33 @@ public class BPTC_68_36 extends BPTCBase
{
CorrectedBinaryMessage deinterleaved = deinterleave(message);
correct(deinterleaved);
+ return extractPayload(deinterleaved);
+ }
+ /**
+ * Applies soft decision decoding to the soft bits and extracts the 36-bit payload. Can be used when the hard
+ * decision result fails BPTC error correction or the CRC-8 check.
+ * @param softBits for the 68 (interleaved) bits of the message where a positive value favors a 1 bit.
+ * @return decoded message, or null if soft decision decoding doesn't converge.
+ */
+ public CorrectedBinaryMessage extractSoft(float[] softBits)
+ {
+ if(softBits == null || softBits.length != LENGTH)
+ {
+ return null;
+ }
+
+ CorrectedBinaryMessage decoded = SOFT_DECODER.decode(deinterleave(softBits));
+ return decoded != null ? extractPayload(decoded) : null;
+ }
+
+ /**
+ * Extracts the 36-bit payload from the corrected (de-interleaved) message.
+ * @param deinterleaved message
+ * @return extracted payload with the corrected bit count transferred.
+ */
+ private static CorrectedBinaryMessage extractPayload(CorrectedBinaryMessage deinterleaved)
+ {
//Extract the message
CorrectedBinaryMessage extractedMessage = new CorrectedBinaryMessage(36);
@@ -59,6 +118,24 @@ public class BPTC_68_36 extends BPTCBase
}
+ /**
+ * Deinterleaves the transmitted soft bits.
+ * @param interleaved soft bits
+ * @return deinterleaved soft bits.
+ */
+ public static float[] deinterleave(float[] interleaved)
+ {
+ float[] deinterleaved = new float[LENGTH];
+
+ for(int index = 0; index < 67; index++)
+ {
+ deinterleaved[(index * 17) % 67] = interleaved[index];
+ }
+
+ deinterleaved[67] = interleaved[67];
+ return deinterleaved;
+ }
+
/**
* Deinterleaves the transmitted message.
* @param interleaved message
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/CACH.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/CACH.java
index 613fe456..f939bcc1 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/CACH.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/CACH.java
@@ -23,6 +23,7 @@ import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.bits.IntField;
import io.github.dsheirer.module.decode.dmr.message.type.LCSS;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
@@ -59,6 +60,7 @@ public class CACH
private CorrectedBinaryMessage mMessage;
private boolean mValid;
+ private float[] mPayloadSoftBits;
/**
* Constructs an instance. Note: constructor is private. Use the getCACH() method to extract a CACH from a
@@ -172,6 +174,42 @@ public class CACH
return mMessage.getSubMessage(PAYLOAD_START, PAYLOAD_END);
}
+ /**
+ * Soft bits for the 17-bit payload message fragment.
+ * @return soft bits where a positive value favors a 1 bit, or null if soft bits are not available.
+ */
+ public float[] getPayloadSoftBits()
+ {
+ return mPayloadSoftBits;
+ }
+
+ /**
+ * Utility method to create a CACH from a raw transmitted DMR burst frame. Performs deinterleaving and error
+ * correction and retains the deinterleaved payload soft bits when the burst soft bits are provided.
+ *
+ * @param message containing the raw transmitted DMR burst
+ * @param softBits for the DMR burst (one per message bit) where a positive value favors a 1 bit, or null.
+ * @return constructed CACH message.
+ */
+ public static CACH getCACH(CorrectedBinaryMessage message, float[] softBits)
+ {
+ CACH cach = getCACH(message);
+
+ if(softBits != null && softBits.length >= CACH_MESSAGE_LENGTH)
+ {
+ float[] decodedSoftBits = new float[CACH_MESSAGE_LENGTH];
+
+ for(int x = 0; x < CACH_MESSAGE_LENGTH; x++)
+ {
+ decodedSoftBits[x] = softBits[INTERLEAVE_MATRIX[x]];
+ }
+
+ cach.mPayloadSoftBits = Arrays.copyOfRange(decodedSoftBits, PAYLOAD_START, PAYLOAD_END);
+ }
+
+ return cach;
+ }
+
/**
* Utility method to create a CACH from a raw transmitted DMR burst frame. Performs deinterleaving and error
* correction.
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/DMRMessageFactory.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/DMRMessageFactory.java
index 2ad42c8a..58fffd98 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/DMRMessageFactory.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/DMRMessageFactory.java
@@ -59,6 +59,22 @@ public class DMRMessageFactory
*/
public IMessage create(DMRSyncPattern syncPattern, CorrectedBinaryMessage binaryMessage, CACH cach, long timestamp,
int timeslot)
+ {
+ return create(syncPattern, binaryMessage, null, cach, timestamp, timeslot);
+ }
+
+ /**
+ * Creates a DMR message wrapper for the binary message. DMR message type is specified by the sync pattern.
+ * Data messages use soft decision decoding when the soft bits are provided.
+ * @param syncPattern for the message
+ * @param binaryMessage containing the raw DMR message burst
+ * @param softBits for the DMR burst (one per message bit) where a positive value favors a 1 bit, or null.
+ * @param timestamp for the burst
+ * @param timeslot for the burst
+ * @return DMRMessage instance or null for UNKNOWN sync pattern
+ */
+ public IMessage create(DMRSyncPattern syncPattern, CorrectedBinaryMessage binaryMessage, float[] softBits, CACH cach,
+ long timestamp, int timeslot)
{
switch(syncPattern)
{
@@ -67,18 +83,18 @@ public class DMRMessageFactory
case MS_VOICE_FRAME_B, MS_VOICE_FRAME_C, MS_VOICE_FRAME_D, MS_VOICE_FRAME_E, MS_VOICE_FRAME_F:
case DIRECT_VOICE_FRAME_B, DIRECT_VOICE_FRAME_C, DIRECT_VOICE_FRAME_D, DIRECT_VOICE_FRAME_E,
DIRECT_VOICE_FRAME_F:
- return createVoiceMessage(syncPattern, binaryMessage, cach, timestamp, timeslot);
+ return createVoiceMessage(syncPattern, binaryMessage, softBits, cach, timestamp, timeslot);
case DIRECT_VOICE_TIMESLOT_1:
- return createVoiceMessage(syncPattern, binaryMessage, cach, timestamp, 1);
+ return createVoiceMessage(syncPattern, binaryMessage, softBits, cach, timestamp, 1);
case DIRECT_VOICE_TIMESLOT_2:
- return createVoiceMessage(syncPattern, binaryMessage, cach, timestamp, 2);
+ return createVoiceMessage(syncPattern, binaryMessage, softBits, cach, timestamp, 2);
case BASE_STATION_DATA:
case MOBILE_STATION_DATA:
- return mDataMessageFactory.create(syncPattern, binaryMessage, cach, timestamp, timeslot);
+ return mDataMessageFactory.create(syncPattern, binaryMessage, softBits, cach, timestamp, timeslot);
case DIRECT_DATA_TIMESLOT_1:
- return mDataMessageFactory.create(syncPattern, binaryMessage, cach, timestamp, 1);
+ return mDataMessageFactory.create(syncPattern, binaryMessage, softBits, cach, timestamp, 1);
case DIRECT_DATA_TIMESLOT_2:
- return mDataMessageFactory.create(syncPattern, binaryMessage, cach, timestamp, 2);
+ return mDataMessageFactory.create(syncPattern, binaryMessage, softBits, cach, timestamp, 2);
case DIRECT_EMPTY_TIMESLOT:
return new EmptyTimeslotPlaceholderMessage(timestamp, 288, Protocol.DMR, timeslot);
case UNKNOWN:
@@ -94,13 +110,14 @@ public class DMRMessageFactory
* Creates a voice message from the raw transmitted DMR burst frame
* @param syncPattern for the frame
* @param message containing the DMR burst
+ * @param softBits for the DMR burst, or null
* @param cach extracted from the first 24 bits of the message
* @param timestamp the message was received
* @param timeslot for the DMR burst
* @return constructed voice message
*/
- private static VoiceMessage createVoiceMessage(DMRSyncPattern syncPattern, CorrectedBinaryMessage message, CACH cach,
- long timestamp, int timeslot)
+ private static VoiceMessage createVoiceMessage(DMRSyncPattern syncPattern, CorrectedBinaryMessage message,
+ float[] softBits, CACH cach, long timestamp, int timeslot)
{
switch(syncPattern)
{
@@ -114,7 +131,7 @@ public class DMRMessageFactory
case DIRECT_VOICE_FRAME_B, DIRECT_VOICE_FRAME_C, DIRECT_VOICE_FRAME_D, DIRECT_VOICE_FRAME_E,
DIRECT_VOICE_FRAME_F:
default:
- return new VoiceEMBMessage(syncPattern, message, cach, timestamp, timeslot);
+ return new VoiceEMBMessage(syncPattern, message, softBits, cach, timestamp, timeslot);
}
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/DMRDataMessageFactory.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/DMRDataMessageFactory.java
index 109382f2..e43935b1 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/DMRDataMessageFactory.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/DMRDataMessageFactory.java
@@ -87,6 +87,23 @@ public class DMRDataMessageFactory
*/
public DataMessage create(DMRSyncPattern pattern, CorrectedBinaryMessage message, CACH cach, long timestamp,
int timeslot)
+ {
+ return create(pattern, message, null, cach, timestamp, timeslot);
+ }
+
+ /**
+ * Creates a data message class, using soft decision decoding for the BPTC(196,96) payload when the soft bits are
+ * provided.
+ * @param pattern for the DMR burst
+ * @param message DMR burst as transmitted
+ * @param softBits for the DMR burst (one per message bit) where a positive value favors a 1 bit, or null.
+ * @param cach from the DMR burst
+ * @param timestamp for the message
+ * @param timeslot for the message
+ * @return data message instance
+ */
+ public DataMessage create(DMRSyncPattern pattern, CorrectedBinaryMessage message, float[] softBits, CACH cach,
+ long timestamp, int timeslot)
{
SlotType slotType = SlotType.getSlotType(message);
@@ -101,181 +118,225 @@ public class DMRDataMessageFactory
}
//Get the BPTC(196,96) protected payload. If the corrected bit count is -1, the payload/message is invalid
- CorrectedBinaryMessage payload = getPayload(message);
+ CorrectedBinaryMessage payload = getPayload(message, softBits);
+ DataMessage dataMessage = createBPTCMessage(pattern, payload, cach, slotType, timestamp, timeslot);
- switch(slotType.getDataType())
+ //The hard decision decoder can converge on an incorrect codeword (miscorrection) when the error count
+ //exceeds its correction capability, which then fails the message integrity (ie CRC) check. Soft decision
+ //decoding is otherwise only applied when hard decision decoding fails, so retry with soft decision
+ //decoding and use the soft decision message if it passes the integrity check.
+ if(!dataMessage.isValid() && softBits != null && payload.getCorrectedBitCount() >= 0)
{
- case SLOT_IDLE:
- DataMessage idle = new IDLEMessage(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- idle.setValid(false);
- }
- return idle;
- case CSBK:
- DataMessage csbk = CSBKMessageFactory.create(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- csbk.setValid(false);
- }
- return csbk;
- case USB_DATA:
- DataMessage usb = new USBData(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- usb.setValid(false);
- }
- return usb;
- case MBC_ENC_HEADER:
- case MBC_HEADER:
- DataMessage mbc = new MBCHeader(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- mbc.setValid(false);
- }
- return mbc;
- case CHANNEL_CONTROL_ENC_HEADER:
- case PI_HEADER:
- LCMessage piLinkControl = mLinkControlMessageFactory.createFullEncryption(payload, timestamp,
+ CorrectedBinaryMessage softPayload = BPTC_196_96.extractSoft(extractSoftBits(softBits));
+
+ if(softPayload != null)
+ {
+ DataMessage softDataMessage = createBPTCMessage(pattern, softPayload, cach, slotType, timestamp,
timeslot);
- DataMessage pi = new PiHeader(pattern, payload, cach, slotType, timestamp, timeslot, piLinkControl);
- if(payload.getCorrectedBitCount() < 0)
- {
- pi.setValid(false);
- }
- return pi;
- case VOICE_HEADER:
- LCMessage vhLinkControl = mLinkControlMessageFactory.createFull(payload, timestamp, timeslot, false);
- DataMessage voice = new VoiceHeader(pattern, payload, cach, slotType, timestamp, timeslot,
- vhLinkControl);
- if(payload.getCorrectedBitCount() < 0)
- {
- voice.setValid(false);
- }
- return voice;
- case DATA_ENC_HEADER:
- case DATA_HEADER:
- int crcBitCount = CRCDMR.correctCCITT80(payload, 0, 80, 0xCCCC);
- boolean valid = crcBitCount < 2 && payload.getCorrectedBitCount() != -1;
- DataPacketFormat dpf = DataHeader.getDataPacketFormat(payload);
- switch(dpf)
+ if(softDataMessage.isValid())
{
- case CONFIRMED_DATA_PACKET:
- ConfirmedDataHeader cdh = new ConfirmedDataHeader(pattern, payload, cach, slotType,
- timestamp, timeslot);
- cdh.setValid(valid);
- return cdh;
- case PROPRIETARY_DATA_PACKET:
- Vendor vendor = ProprietaryDataHeader.getVendor(payload);
-
- switch(vendor)
- {
- case MOTOROLA_CAPACITY_PLUS:
- case MOTOROLA_CONNECT_PLUS:
- ServiceAccessPoint sap = ProprietaryDataHeader.getServiceAccessPoint(payload);
-
- if(sap == ServiceAccessPoint.SAP_1)
- {
- MNISProprietaryDataHeader mpdh = new MNISProprietaryDataHeader(pattern, payload,
- cach, slotType, timestamp, timeslot);
- mpdh.setValid(valid);
- return mpdh;
- }
- else
- {
- MotorolaDataEncryptionHeader mprdh = new MotorolaDataEncryptionHeader(pattern,
- payload, cach, slotType, timestamp, timeslot);
- mprdh.setValid(valid);
- return mprdh;
- }
- case HYTERA_68:
- HyteraDataEncryptionHeader hsdh = new HyteraDataEncryptionHeader(pattern, payload,
- cach, slotType, timestamp, timeslot);
- hsdh.setValid(valid);
- return hsdh;
- default:
- ProprietaryDataHeader pdh = new ProprietaryDataHeader(pattern, payload, cach,
- slotType, timestamp, timeslot);
- pdh.setValid(valid);
- return pdh;
- }
- case RAW_OR_STATUS_SHORT_DATA:
- int appendedBlockCount = ShortDataHeader.getAppendedBlocks(payload);
-
- if(appendedBlockCount == 0)
- {
- StatusDataHeader sdh = new StatusDataHeader(pattern, payload, cach, slotType, timestamp,
- timeslot);
- sdh.setValid(valid);
- return sdh;
- }
- else
- {
- RawShortDataHeader rsdh = new RawShortDataHeader(pattern, payload, cach, slotType,
- timestamp, timeslot);
- rsdh.setValid(valid);
- return rsdh;
- }
- case RESPONSE_PACKET:
- ResponseDataHeader rdh = new ResponseDataHeader(pattern, payload, cach, slotType, timestamp,
- timeslot);
- rdh.setValid(valid);
- return rdh;
- case DEFINED_SHORT_DATA:
- DefinedShortDataHeader dsdh = new DefinedShortDataHeader(pattern, payload, cach,slotType,
- timestamp, timeslot);
- dsdh.setValid(valid);
- return dsdh;
- case UNCONFIRMED_DATA_PACKET:
- UnconfirmedDataHeader udh = new UnconfirmedDataHeader(pattern, payload, cach, slotType,
- timestamp, timeslot);
- udh.setValid(valid);
- return udh;
- case UNIFIED_DATA_TRANSPORT:
- //TODO: this will eventually need its own factory
- UDTHeader uh = new UDTHeader(pattern, payload, cach, slotType, timestamp, timeslot);
- uh.setValid(valid);
- return uh;
- default:
- DataHeader dh = new DataHeader(pattern, payload, cach, slotType, timestamp, timeslot);
- dh.setValid(valid);
- return dh;
+ return softDataMessage;
}
- case TLC:
- LCMessage tlcLinkControl = mLinkControlMessageFactory.createFull(payload, timestamp, timeslot, true);
- DataMessage tlc = new Terminator(pattern, payload, cach, slotType, timestamp, timeslot, tlcLinkControl);
- if(payload.getCorrectedBitCount() < 0)
- {
- tlc.setValid(false);
- }
- return tlc;
- case RATE_1_OF_2_DATA:
- DataMessage data = new DataBlock1_2Rate(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- data.setValid(false);
- }
- return data;
- case MBC_BLOCK:
- DataMessage mbcBlock = new MBCContinuationBlock(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- mbcBlock.setValid(false);
- }
- return mbcBlock;
- case RESERVED_15:
- case UNKNOWN:
- DataMessage unk = new UnknownDataMessage(pattern, payload, cach, slotType, timestamp, timeslot);
- if(payload.getCorrectedBitCount() < 0)
- {
- unk.setValid(false);
- }
- return unk;
+ }
}
+
+ return dataMessage;
+ }
+
+ CorrectedBinaryMessage payload = getPayload(message, softBits);
+ DataMessage unk = new UnknownDataMessage(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ unk.setValid(false);
+ }
+ return unk;
+ }
+
+ /**
+ * Creates a data message from the BPTC(196,96) protected payload according to the slot type data type.
+ * @param pattern for the DMR burst
+ * @param payload extracted from the BPTC(196,96) protected message
+ * @param cach from the DMR burst
+ * @param slotType for the DMR burst
+ * @param timestamp for the message
+ * @param timeslot for the message
+ * @return data message instance
+ */
+ private DataMessage createBPTCMessage(DMRSyncPattern pattern, CorrectedBinaryMessage payload, CACH cach,
+ SlotType slotType, long timestamp, int timeslot)
+ {
+ switch(slotType.getDataType())
+ {
+ case SLOT_IDLE:
+ DataMessage idle = new IDLEMessage(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ idle.setValid(false);
+ }
+ return idle;
+ case CSBK:
+ DataMessage csbk = CSBKMessageFactory.create(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ csbk.setValid(false);
+ }
+ return csbk;
+ case USB_DATA:
+ DataMessage usb = new USBData(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ usb.setValid(false);
+ }
+ return usb;
+ case MBC_ENC_HEADER:
+ case MBC_HEADER:
+ DataMessage mbc = new MBCHeader(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ mbc.setValid(false);
+ }
+ return mbc;
+ case CHANNEL_CONTROL_ENC_HEADER:
+ case PI_HEADER:
+ LCMessage piLinkControl = mLinkControlMessageFactory.createFullEncryption(payload, timestamp,
+ timeslot);
+ DataMessage pi = new PiHeader(pattern, payload, cach, slotType, timestamp, timeslot, piLinkControl);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ pi.setValid(false);
+ }
+ return pi;
+ case VOICE_HEADER:
+ LCMessage vhLinkControl = mLinkControlMessageFactory.createFull(payload, timestamp, timeslot, false);
+ DataMessage voice = new VoiceHeader(pattern, payload, cach, slotType, timestamp, timeslot,
+ vhLinkControl);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ voice.setValid(false);
+ }
+ return voice;
+ case DATA_ENC_HEADER:
+ case DATA_HEADER:
+ int crcBitCount = CRCDMR.correctCCITT80(payload, 0, 80, 0xCCCC);
+ boolean valid = crcBitCount < 2 && payload.getCorrectedBitCount() != -1;
+ DataPacketFormat dpf = DataHeader.getDataPacketFormat(payload);
+
+ switch(dpf)
+ {
+ case CONFIRMED_DATA_PACKET:
+ ConfirmedDataHeader cdh = new ConfirmedDataHeader(pattern, payload, cach, slotType,
+ timestamp, timeslot);
+ cdh.setValid(valid);
+ return cdh;
+ case PROPRIETARY_DATA_PACKET:
+ Vendor vendor = ProprietaryDataHeader.getVendor(payload);
+
+ switch(vendor)
+ {
+ case MOTOROLA_CAPACITY_PLUS:
+ case MOTOROLA_CONNECT_PLUS:
+ ServiceAccessPoint sap = ProprietaryDataHeader.getServiceAccessPoint(payload);
+
+ if(sap == ServiceAccessPoint.SAP_1)
+ {
+ MNISProprietaryDataHeader mpdh = new MNISProprietaryDataHeader(pattern, payload,
+ cach, slotType, timestamp, timeslot);
+ mpdh.setValid(valid);
+ return mpdh;
+ }
+ else
+ {
+ MotorolaDataEncryptionHeader mprdh = new MotorolaDataEncryptionHeader(pattern,
+ payload, cach, slotType, timestamp, timeslot);
+ mprdh.setValid(valid);
+ return mprdh;
+ }
+ case HYTERA_68:
+ HyteraDataEncryptionHeader hsdh = new HyteraDataEncryptionHeader(pattern, payload,
+ cach, slotType, timestamp, timeslot);
+ hsdh.setValid(valid);
+ return hsdh;
+ default:
+ ProprietaryDataHeader pdh = new ProprietaryDataHeader(pattern, payload, cach,
+ slotType, timestamp, timeslot);
+ pdh.setValid(valid);
+ return pdh;
+ }
+ case RAW_OR_STATUS_SHORT_DATA:
+ int appendedBlockCount = ShortDataHeader.getAppendedBlocks(payload);
+
+ if(appendedBlockCount == 0)
+ {
+ StatusDataHeader sdh = new StatusDataHeader(pattern, payload, cach, slotType, timestamp,
+ timeslot);
+ sdh.setValid(valid);
+ return sdh;
+ }
+ else
+ {
+ RawShortDataHeader rsdh = new RawShortDataHeader(pattern, payload, cach, slotType,
+ timestamp, timeslot);
+ rsdh.setValid(valid);
+ return rsdh;
+ }
+ case RESPONSE_PACKET:
+ ResponseDataHeader rdh = new ResponseDataHeader(pattern, payload, cach, slotType, timestamp,
+ timeslot);
+ rdh.setValid(valid);
+ return rdh;
+ case DEFINED_SHORT_DATA:
+ DefinedShortDataHeader dsdh = new DefinedShortDataHeader(pattern, payload, cach,slotType,
+ timestamp, timeslot);
+ dsdh.setValid(valid);
+ return dsdh;
+ case UNCONFIRMED_DATA_PACKET:
+ UnconfirmedDataHeader udh = new UnconfirmedDataHeader(pattern, payload, cach, slotType,
+ timestamp, timeslot);
+ udh.setValid(valid);
+ return udh;
+ case UNIFIED_DATA_TRANSPORT:
+ //TODO: this will eventually need its own factory
+ UDTHeader uh = new UDTHeader(pattern, payload, cach, slotType, timestamp, timeslot);
+ uh.setValid(valid);
+ return uh;
+ default:
+ DataHeader dh = new DataHeader(pattern, payload, cach, slotType, timestamp, timeslot);
+ dh.setValid(valid);
+ return dh;
+ }
+ case TLC:
+ LCMessage tlcLinkControl = mLinkControlMessageFactory.createFull(payload, timestamp, timeslot, true);
+ DataMessage tlc = new Terminator(pattern, payload, cach, slotType, timestamp, timeslot, tlcLinkControl);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ tlc.setValid(false);
+ }
+ return tlc;
+ case RATE_1_OF_2_DATA:
+ DataMessage data = new DataBlock1_2Rate(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ data.setValid(false);
+ }
+ return data;
+ case MBC_BLOCK:
+ DataMessage mbcBlock = new MBCContinuationBlock(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ mbcBlock.setValid(false);
+ }
+ return mbcBlock;
+ case RESERVED_15:
+ case UNKNOWN:
+ DataMessage unk = new UnknownDataMessage(pattern, payload, cach, slotType, timestamp, timeslot);
+ if(payload.getCorrectedBitCount() < 0)
+ {
+ unk.setValid(false);
+ }
+ return unk;
}
- CorrectedBinaryMessage payload = getPayload(message);
DataMessage unk = new UnknownDataMessage(pattern, payload, cach, slotType, timestamp, timeslot);
if(payload.getCorrectedBitCount() < 0)
{
@@ -299,11 +360,30 @@ public class DMRDataMessageFactory
* De-scramble, decode and error check a BPTC protected raw message and return a 96-bit error corrected
* payload or null.
* @param message containing a payload
+ * @param softBits for the message, or null
*/
- private static CorrectedBinaryMessage getPayload(CorrectedBinaryMessage message)
+ private static CorrectedBinaryMessage getPayload(CorrectedBinaryMessage message, float[] softBits)
{
CorrectedBinaryMessage extracted = extract(message);
- return BPTC_196_96.extract(extracted);
+ return BPTC_196_96.extract(extracted, extractSoftBits(softBits));
+ }
+
+ /**
+ * Extracts the 196 soft bits of the message payload from the full DMR burst soft bits
+ * @param softBits for the DMR burst, or null
+ * @return extracted soft bits or null
+ */
+ private static float[] extractSoftBits(float[] softBits)
+ {
+ if(softBits == null)
+ {
+ return null;
+ }
+
+ float[] extracted = new float[196];
+ System.arraycopy(softBits, 24, extracted, 0, 98);
+ System.arraycopy(softBits, 190, extracted, 98, 98);
+ return extracted;
}
/**
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/full/FLCAssembler.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/full/FLCAssembler.java
index 94debd05..eaa2edab 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/full/FLCAssembler.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/full/FLCAssembler.java
@@ -33,6 +33,9 @@ import io.github.dsheirer.module.decode.dmr.message.type.LCSS;
public class FLCAssembler
{
private CorrectedBinaryMessage mAssemblingMessage;
+ //Soft bits parallel to the assembling message, or null when any fragment doesn't provide soft bits.
+ private float[] mAssemblingSoftBits;
+ private int mSoftBitsOffset;
private int mFragmentCount = 0;
private long mTimestamp;
private final int mTimeslot;
@@ -54,6 +57,7 @@ public class FLCAssembler
public void reset()
{
mAssemblingMessage = null;
+ mAssemblingSoftBits = null;
mFragmentCount = 0;
}
@@ -62,6 +66,19 @@ public class FLCAssembler
* @return a full link control message or null if the message is not yet fully assembled
*/
public FullLCMessage process(LCSS lcss, BinaryMessage fragment, long timestamp)
+ {
+ return process(lcss, fragment, null, timestamp);
+ }
+
+ /**
+ * Processes the full link control fragment
+ * @param lcss link control start/stop for the fragment
+ * @param fragment of 32 bits
+ * @param softFragment soft bits for the fragment where a positive value favors a 1 bit, or null.
+ * @param timestamp of the fragment
+ * @return a full link control message or null if the message is not yet fully assembled
+ */
+ public FullLCMessage process(LCSS lcss, BinaryMessage fragment, float[] softFragment, long timestamp)
{
mTimestamp = timestamp;
@@ -71,24 +88,22 @@ public class FLCAssembler
{
case FIRST_FRAGMENT:
message = dispatch();
- mAssemblingMessage = new CorrectedBinaryMessage(128);
- add(fragment);
+ start(0);
+ add(fragment, softFragment);
break;
case CONTINUATION_FRAGMENT:
if(mAssemblingMessage == null)
{
- mAssemblingMessage = new CorrectedBinaryMessage(128);
- mAssemblingMessage.setPointer(32);
+ start(32);
}
- add(fragment);
+ add(fragment, softFragment);
break;
case LAST_FRAGMENT:
if(mAssemblingMessage == null)
{
- mAssemblingMessage = new CorrectedBinaryMessage(128);
- mAssemblingMessage.setPointer(96);
+ start(96);
}
- add(fragment);
+ add(fragment, softFragment);
message = dispatch();
break;
case SINGLE_FRAGMENT:
@@ -102,6 +117,18 @@ public class FLCAssembler
return message;
}
+ /**
+ * Starts assembling a new message
+ * @param offset for the first fragment
+ */
+ private void start(int offset)
+ {
+ mAssemblingMessage = new CorrectedBinaryMessage(128);
+ mAssemblingMessage.setPointer(offset);
+ mAssemblingSoftBits = new float[128];
+ mSoftBitsOffset = offset;
+ }
+
/**
* Dispatches a fully-received message
*/
@@ -111,10 +138,11 @@ public class FLCAssembler
if(mFragmentCount == 4)
{
- message = decode(mAssemblingMessage, mTimestamp, mTimeslot);
+ message = decode(mAssemblingMessage, mAssemblingSoftBits, mTimestamp, mTimeslot);
}
mAssemblingMessage = null;
+ mAssemblingSoftBits = null;
mFragmentCount = 0;
return message;
}
@@ -122,11 +150,11 @@ public class FLCAssembler
/**
* Decodes an assembled FLC message, performs error detection and correction and returns a constructed FLC message
*/
- private FullLCMessage decode(BinaryMessage message, long timestamp, int timeslot)
+ private FullLCMessage decode(BinaryMessage message, float[] softBits, long timestamp, int timeslot)
{
if(message != null)
{
- CorrectedBinaryMessage extractedMessage = mBPTC.extract(message);
+ CorrectedBinaryMessage extractedMessage = mBPTC.extract(message, softBits);
FullLCMessage flco = mMessageFactory.createFull(extractedMessage, timestamp, timeslot, false);
if(extractedMessage.getCorrectedBitCount() < 0)
@@ -141,9 +169,11 @@ public class FLCAssembler
}
/**
- * Adds the cach payload to the currently assembling message
+ * Adds the embedded signalling fragment to the currently assembling message
+ * @param payload fragment bits
+ * @param softPayload fragment soft bits, or null
*/
- private void add(BinaryMessage payload)
+ private void add(BinaryMessage payload, float[] softPayload)
{
if(payload == null || payload.size() != 32)
{
@@ -152,6 +182,17 @@ public class FLCAssembler
mFragmentCount++;
+ if(mAssemblingSoftBits != null && softPayload != null && softPayload.length == 32 &&
+ mSoftBitsOffset + 32 <= mAssemblingSoftBits.length)
+ {
+ System.arraycopy(softPayload, 0, mAssemblingSoftBits, mSoftBitsOffset, 32);
+ mSoftBitsOffset += 32;
+ }
+ else
+ {
+ mAssemblingSoftBits = null;
+ }
+
try
{
if(mAssemblingMessage == null)
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssembler.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssembler.java
index 1cd84a42..4c4e527d 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssembler.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssembler.java
@@ -35,6 +35,9 @@ public class SLCAssembler
{
private final static Logger mLog = LoggerFactory.getLogger(SLCAssembler.class);
private CorrectedBinaryMessage mAssemblingMessage;
+ //Soft bits parallel to the assembling message, or null when any fragment doesn't provide soft bits.
+ private float[] mAssemblingSoftBits;
+ private int mSoftBitsOffset;
private int mFragmentCount = 0;
private long mTimestamp;
private BPTC_68_36 mBPTC = new BPTC_68_36();
@@ -52,6 +55,7 @@ public class SLCAssembler
public void reset()
{
mAssemblingMessage = null;
+ mAssemblingSoftBits = null;
mFragmentCount = 0;
mTimestamp = System.currentTimeMillis();
}
@@ -61,6 +65,19 @@ public class SLCAssembler
* @return a short link control message or null if the message is not yet fully assembled
*/
public ShortLCMessage process(LCSS lcss, BinaryMessage fragment, long timestamp)
+ {
+ return process(lcss, fragment, null, timestamp);
+ }
+
+ /**
+ * Processes the short link control fragment
+ * @param lcss link control start/stop for the fragment
+ * @param fragment of 17 bits
+ * @param softFragment soft bits for the fragment where a positive value favors a 1 bit, or null.
+ * @param timestamp of the fragment
+ * @return a short link control message or null if the message is not yet fully assembled
+ */
+ public ShortLCMessage process(LCSS lcss, BinaryMessage fragment, float[] softFragment, long timestamp)
{
mTimestamp = timestamp;
@@ -70,24 +87,22 @@ public class SLCAssembler
{
case FIRST_FRAGMENT:
message = dispatch();
- mAssemblingMessage = new CorrectedBinaryMessage(68);
- add(fragment);
+ start(0);
+ add(fragment, softFragment);
break;
case CONTINUATION_FRAGMENT:
if(mAssemblingMessage == null)
{
- mAssemblingMessage = new CorrectedBinaryMessage(68);
- mAssemblingMessage.setPointer(17);
+ start(17);
}
- add(fragment);
+ add(fragment, softFragment);
break;
case LAST_FRAGMENT:
if(mAssemblingMessage == null)
{
- mAssemblingMessage = new CorrectedBinaryMessage(68);
- mAssemblingMessage.setPointer(51);
+ start(51);
}
- add(fragment);
+ add(fragment, softFragment);
message = dispatch();
break;
case SINGLE_FRAGMENT:
@@ -118,34 +133,63 @@ public class SLCAssembler
return LCMessageFactory.createShort(corrected, mTimestamp, 0);
}
+ /**
+ * Starts assembling a new message
+ * @param offset for the first fragment
+ */
+ private void start(int offset)
+ {
+ mAssemblingMessage = new CorrectedBinaryMessage(68);
+ mAssemblingMessage.setPointer(offset);
+ mAssemblingSoftBits = new float[68];
+ mSoftBitsOffset = offset;
+ }
+
private ShortLCMessage dispatch()
{
ShortLCMessage message = null;
if(mFragmentCount == 4)
{
- message = decode(mAssemblingMessage, mTimestamp);
+ message = decode(mAssemblingMessage, mAssemblingSoftBits, mTimestamp);
}
mAssemblingMessage = null;
+ mAssemblingSoftBits = null;
mFragmentCount = 0;
return message;
}
/**
* Performs error detection and correction and extracts the message from the BPTC protected interleaved message.
+ * When the hard decision result fails BPTC error correction or the CRC-8 check and soft bits are provided,
+ * retries with soft decision decoding and uses the soft decision result if it passes the CRC-8 check.
* @param interleaved message
+ * @param softBits for the interleaved message, or null.
* @param timestamp for the decoded message.
* @return decoded message.
*/
- private ShortLCMessage decode(BinaryMessage interleaved, long timestamp)
+ private ShortLCMessage decode(BinaryMessage interleaved, float[] softBits, long timestamp)
{
if(interleaved != null)
{
- CorrectedBinaryMessage corrected = mBPTC.extract(interleaved);
- //Note: slco is timeslot-agnostic, so we use timeslot 0 every time
- ShortLCMessage slco = LCMessageFactory.createShort(corrected, timestamp, 0);
- slco.setValid(corrected.getCorrectedBitCount() >= 0);
+ ShortLCMessage slco = create(mBPTC.extract(interleaved), timestamp);
+
+ if(!slco.isValid() && softBits != null)
+ {
+ CorrectedBinaryMessage softCorrected = mBPTC.extractSoft(softBits);
+
+ if(softCorrected != null)
+ {
+ ShortLCMessage softSlco = create(softCorrected, timestamp);
+
+ if(softSlco.isValid())
+ {
+ return softSlco;
+ }
+ }
+ }
+
return slco;
}
@@ -153,9 +197,26 @@ public class SLCAssembler
}
/**
- * Adds the cach payload to the currently assembling message
+ * Creates a short link control message from the corrected message.
+ * @param corrected message with the BPTC corrected bit count
+ * @param timestamp for the message
+ * @return message
*/
- private void add(BinaryMessage payload)
+ private static ShortLCMessage create(CorrectedBinaryMessage corrected, long timestamp)
+ {
+ //Note: slco is timeslot-agnostic, so we use timeslot 0 every time
+ ShortLCMessage slco = LCMessageFactory.createShort(corrected, timestamp, 0);
+ //Valid requires both the CRC-8 check (set by the factory) and successful BPTC error correction
+ slco.setValid(slco.isValid() && corrected.getCorrectedBitCount() >= 0);
+ return slco;
+ }
+
+ /**
+ * Adds the cach payload to the currently assembling message
+ * @param payload fragment bits
+ * @param softPayload fragment soft bits, or null
+ */
+ private void add(BinaryMessage payload, float[] softPayload)
{
if(payload == null || payload.size() != 17)
{
@@ -164,6 +225,17 @@ public class SLCAssembler
mFragmentCount++;
+ if(mAssemblingSoftBits != null && softPayload != null && softPayload.length == 17 &&
+ mSoftBitsOffset + 17 <= mAssemblingSoftBits.length)
+ {
+ System.arraycopy(softPayload, 0, mAssemblingSoftBits, mSoftBitsOffset, 17);
+ mSoftBitsOffset += 17;
+ }
+ else
+ {
+ mAssemblingSoftBits = null;
+ }
+
try
{
if(mAssemblingMessage == null)
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java
index 8e137e9f..01042299 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/EMB.java
@@ -56,6 +56,8 @@ public class EMB
private static final int[] COLOR_CODE = new int[]{0, 1, 2, 3};
private static final int ENCRYPTION_PI = 4;
private static final int[] LINK_CONTROL_START_STOP = new int[]{5, 6};
+ //Maximum bit errors between the hard decision and the soft decision (maximum likelihood) word for validity.
+ private static final int SOFT_DECISION_MAX_BIT_ERRORS = 2;
private CorrectedBinaryMessage mMessage;
private boolean mValid = true;
@@ -71,6 +73,68 @@ public class EMB
checkCRC();
}
+ /**
+ * Constructs an instance using maximum likelihood soft decision decoding when the soft bits are provided.
+ *
+ * @param message with transmitted bits
+ * @param softBits for the 16 transmitted bits where a positive value favors a 1 bit, or null.
+ */
+ public EMB(CorrectedBinaryMessage message, float[] softBits)
+ {
+ mMessage = message;
+
+ if(softBits != null && softBits.length == WORD.length)
+ {
+ decodeSoft(softBits);
+ }
+ else
+ {
+ checkCRC();
+ }
+ }
+
+ /**
+ * Maximum likelihood soft decision decoding against all valid words. Selects the valid word with the lowest sum
+ * of soft bit magnitudes for the bit positions where the valid word differs from the hard decision.
+ * @param softBits for the 16 transmitted bits.
+ */
+ private void decodeSoft(float[] softBits)
+ {
+ int word = getMessage().getInt(WORD);
+ int best = word;
+ float bestMetric = Float.MAX_VALUE;
+
+ for(int validWord: VALID_WORDS)
+ {
+ int difference = word ^ validWord;
+ float metric = 0.0f;
+
+ while(difference != 0)
+ {
+ int bit = Integer.numberOfTrailingZeros(difference);
+ //Word bit 15 is message bit 0
+ metric += Math.abs(softBits[15 - bit]);
+ difference &= difference - 1;
+ }
+
+ if(metric < bestMetric)
+ {
+ bestMetric = metric;
+ best = validWord;
+ }
+ }
+
+ int bitErrors = Integer.bitCount(word ^ best);
+
+ if(bitErrors > 0)
+ {
+ getMessage().setInt(best, WORD);
+ getMessage().setCorrectedBitCount(bitErrors);
+ }
+
+ mValid = bitErrors <= SOFT_DECISION_MAX_BIT_ERRORS;
+ }
+
/**
* Checks the EMB word using the Quadratic Residue(16,7,6) code as detailed in TS 102 361-1 B.3.2.
*
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceEMBMessage.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceEMBMessage.java
index 4214ab14..b48bbac6 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceEMBMessage.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceEMBMessage.java
@@ -36,6 +36,7 @@ public class VoiceEMBMessage extends VoiceMessage
private static final int PAYLOAD_END = 172;
private EMB mEMB;
+ private final float[] mSoftBits;
private EmbeddedParameters mEmbeddedParameters;
/**
@@ -47,8 +48,23 @@ public class VoiceEMBMessage extends VoiceMessage
* @param message containing 288-bit DMR message with preliminary bit corrections indicated.
*/
public VoiceEMBMessage(DMRSyncPattern syncPattern, CorrectedBinaryMessage message, CACH cach, long timestamp, int timeslot)
+ {
+ this(syncPattern, message, null, cach, timestamp, timeslot);
+ }
+
+ /**
+ * Constructs an instance with soft bits to use for soft decision decoding of the EMB and the embedded signalling
+ * full link control fragment.
+ *
+ * @param syncPattern
+ * @param message containing 288-bit DMR message with preliminary bit corrections indicated.
+ * @param softBits for the 288-bit DMR message where a positive value favors a 1 bit, or null.
+ */
+ public VoiceEMBMessage(DMRSyncPattern syncPattern, CorrectedBinaryMessage message, float[] softBits, CACH cach,
+ long timestamp, int timeslot)
{
super(syncPattern, message, cach, timestamp, timeslot);
+ mSoftBits = softBits;
}
@Override
@@ -90,7 +106,19 @@ public class VoiceEMBMessage extends VoiceMessage
segment.set(x, getMessage().get(EMB[x]));
}
- mEMB = new EMB(segment);
+ float[] softBits = null;
+
+ if(mSoftBits != null)
+ {
+ softBits = new float[EMB.length];
+
+ for(int x = 0; x < EMB.length; x++)
+ {
+ softBits[x] = mSoftBits[EMB[x]];
+ }
+ }
+
+ mEMB = new EMB(segment, softBits);
}
return mEMB;
@@ -104,6 +132,22 @@ public class VoiceEMBMessage extends VoiceMessage
return getMessage().getSubMessage(PAYLOAD_START, PAYLOAD_END);
}
+ /**
+ * Soft bits for the embedded signalling full link control fragment
+ * @return soft bits or null if soft bits are not available.
+ */
+ public float[] getFLCFragmentSoftBits()
+ {
+ if(mSoftBits == null)
+ {
+ return null;
+ }
+
+ float[] softBits = new float[PAYLOAD_END - PAYLOAD_START];
+ System.arraycopy(mSoftBits, PAYLOAD_START, softBits, 0, softBits.length);
+ return softBits;
+ }
+
/**
* Optional embedded parameters for this voice super-frame.
* @return encryption parameters or null.
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/sync/DMRSoftSyncDetector.java b/src/main/java/io/github/dsheirer/module/decode/dmr/sync/DMRSoftSyncDetector.java
index 0c3ad675..b0d8a76c 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/sync/DMRSoftSyncDetector.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/sync/DMRSoftSyncDetector.java
@@ -19,14 +19,23 @@
package io.github.dsheirer.module.decode.dmr.sync;
+import io.github.dsheirer.dsp.c4fm.DibitSyncDetector;
+import io.github.dsheirer.dsp.c4fm.ISoftC4FMSyncDetector;
+import io.github.dsheirer.dsp.c4fm.ReferenceSyncWaveform;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.Map;
/**
* DMR Sync Detector. Uses soft demodulated symbols to detect sync patterns in the demodulated symbol stream.
+ *
+ * Implements the C4FM sync detector interface where the sync pattern accessor methods return the values for the sync
+ * pattern that produced the best correlation score from the most recent call to process().
*/
-public abstract class DMRSoftSyncDetector extends DMRSyncDetector
+public abstract class DMRSoftSyncDetector extends DMRSyncDetector implements ISoftC4FMSyncDetector
{
+ private static final int SYNC_DIBIT_LENGTH = 24;
protected static final float MAX_POSITIVE = Dibit.D01_PLUS_3.getIdealPhase();
protected static final float MAX_NEGATIVE = Dibit.D11_MINUS_3.getIdealPhase();
protected static final float[] BASE_DATA = DMRSyncPattern.BASE_STATION_DATA.toSymbols();
@@ -40,9 +49,47 @@ public abstract class DMRSoftSyncDetector extends DMRSyncDetector
protected float[] mSymbols = new float[48]; //2x longer than 48-bit/24-dibit DMR sync patterns.
protected int mSymbolPointer = 0;
+ //Per-instance sync pattern detectors provide pattern dibits, symbols and (cached) reference waveforms
+ private final Map mPatternDetectors = new EnumMap<>(DMRSyncPattern.class);
public DMRSoftSyncDetector()
{
+ for(DMRSyncPattern pattern: DMRSyncPattern.SYNC_PATTERNS)
+ {
+ mPatternDetectors.put(pattern, new DibitSyncDetector(pattern.getPattern(), SYNC_DIBIT_LENGTH));
+ }
+ }
+
+ /**
+ * Sync pattern detector for the currently detected sync pattern.
+ */
+ private DibitSyncDetector getDetectedPatternDetector()
+ {
+ return mPatternDetectors.get(mDetectedPattern);
+ }
+
+ @Override
+ public float[] getPatternSymbols()
+ {
+ return getDetectedPatternDetector().getPatternSymbols();
+ }
+
+ @Override
+ public Dibit[] getPatternDibits()
+ {
+ return getDetectedPatternDetector().getPatternDibits();
+ }
+
+ @Override
+ public int getDibitLength()
+ {
+ return SYNC_DIBIT_LENGTH;
+ }
+
+ @Override
+ public ReferenceSyncWaveform getReferenceWaveform(double samplesPerSymbol)
+ {
+ return getDetectedPatternDetector().getReferenceWaveform(samplesPerSymbol);
}
/**
@@ -74,23 +121,15 @@ public abstract class DMRSoftSyncDetector extends DMRSyncDetector
* symbols that include this recent soft symbol.
*
* @param dibitSymbol to process that represents the soft demodulated symbol value in phase angle/radians.
+ * @return best correlation score where the best detected pattern is available at getDetectedPattern().
*/
- public void process(float dibitSymbol)
+ @Override
+ public float process(float dibitSymbol)
{
mSymbols[mSymbolPointer] = dibitSymbol;
mSymbols[mSymbolPointer + 24] = dibitSymbol;
mSymbolPointer++;
mSymbolPointer %= 24;
- }
-
- /**
- * Process the symbol and calculate the sync detection score.
- * @param symbol to process
- * @return sync detection score.
- */
- public float processAndCalculate(float symbol)
- {
- process(symbol);
return calculate();
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNMessageFramer.java b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNMessageFramer.java
index 43c94112..b4d6184d 100644
--- a/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNMessageFramer.java
+++ b/src/main/java/io/github/dsheirer/module/decode/nxdn/NXDNMessageFramer.java
@@ -50,7 +50,6 @@ public class NXDNMessageFramer extends C4FMMessageFramer
//Soft symbols (radians) parallel to the dibit buffer, or NaN when the soft symbol wasn't provided
private final float[] mSoftSymbolBuffer = new float[192];
private float mCurrentSoftSymbol = Float.NaN;
- private static final float SOFT_SYMBOL_SCALE = (float)(4.0 / Math.PI); //Radians to symbol units (+/-1, +/-3)
private final LICHTracker mLICHTracker;
private final Listener mMessageListener;
private final NXDNHardSyncDetector mHardSyncDetector = new NXDNHardSyncDetector();
@@ -105,24 +104,6 @@ public class NXDNMessageFramer extends C4FMMessageFramer
}
}
- /**
- * Converts the soft symbol into a pair of soft bit values using max-log likelihood ratios for the Gray mapped
- * 4-level symbol (D01=+3, D00=+1, D10=-1, D11=-3), where a positive soft bit value favors a 1 bit.
- * @param softSymbol in radians
- * @param softBits array to receive the two soft bit values
- * @param index for the first bit (sign bit), followed by the second (magnitude) bit
- */
- private static void setSoftBits(float softSymbol, float[] softBits, int index)
- {
- float u = softSymbol * SOFT_SYMBOL_SCALE;
- float magnitude = Math.abs(u);
- //First bit is 1 for negative symbols
- float favorZero = magnitude <= 2.0f ? 4.0f * u : 8.0f * u - 8.0f * Math.signum(u);
- softBits[index] = -favorZero;
- //Second bit is 1 for the outer (+/-3) symbols
- softBits[index + 1] = 4.0f * (magnitude - 2.0f);
- }
-
/**
* Dispatches a currently assembling message once it's complete.
* @return true if any of the messages from the frame are valid.
diff --git a/src/test/java/io/github/dsheirer/dsp/psk/demod/DmrSoftSyncDetectorTest.java b/src/test/java/io/github/dsheirer/dsp/psk/demod/DmrSoftSyncDetectorTest.java
index 5ff6a840..02b269bb 100644
--- a/src/test/java/io/github/dsheirer/dsp/psk/demod/DmrSoftSyncDetectorTest.java
+++ b/src/test/java/io/github/dsheirer/dsp/psk/demod/DmrSoftSyncDetectorTest.java
@@ -60,11 +60,11 @@ public class DmrSoftSyncDetectorTest
for(Dibit dibit: pattern.toDibits())
{
symbol = toSymbol(dibit);
- scalar.processAndCalculate(symbol);
- vector64.processAndCalculate(symbol);
- vector128.processAndCalculate(symbol);
- vector256.processAndCalculate(symbol);
- vector512.processAndCalculate(symbol);
+ scalar.process(symbol);
+ vector64.process(symbol);
+ vector128.process(symbol);
+ vector256.process(symbol);
+ vector512.process(symbol);
}
assertSame(scalar.getDetectedPattern(), pattern, "Scalar Detector Did Not Correctly Detect Pattern: " + pattern);
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/DMRBurstTestUtility.java b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRBurstTestUtility.java
new file mode 100644
index 00000000..3d76258c
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRBurstTestUtility.java
@@ -0,0 +1,399 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.dsp.symbol.Dibit;
+import io.github.dsheirer.edac.CRCDMR;
+import io.github.dsheirer.edac.Golay24;
+import io.github.dsheirer.edac.Hamming13;
+import io.github.dsheirer.edac.Hamming15;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
+import io.github.dsheirer.message.IMessage;
+import io.github.dsheirer.module.decode.dmr.bptc.BPTC_196_96;
+import io.github.dsheirer.module.decode.dmr.message.type.DataType;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSyncPattern;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Utility for creating DMR data bursts and framing them for unit tests.
+ */
+public class DMRBurstTestUtility
+{
+ public static final int BURST_DIBITS = 144;
+ public static final int DELAY_LINE_DIBITS = 90; //CACH(12) + MESSAGE_PREFIX(54) + SYNC(24)
+ public static final DMRSyncPattern SYNC_PATTERN = DMRSyncPattern.BASE_STATION_DATA;
+ public static final int CSBK_CRC_MASK = 0xA5A5;
+ private static final SoftBlockCode ROW_CODE = SoftBlockCode.fromCodewordTest(15,
+ word -> Hamming15.getSyndrome(toMessage(word, 15), 0) == 0);
+ private static final SoftBlockCode COLUMN_CODE = SoftBlockCode.fromCodewordTest(13,
+ word -> Hamming13.getSyndrome(toMessage(word, 13), new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}) == 0);
+
+ private static BinaryMessage toMessage(int word, int length)
+ {
+ BinaryMessage message = new BinaryMessage(length);
+
+ for(int i = 0; i < length; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Finds the systematic codeword whose information bits (the low bits) match the information value.
+ */
+ private static int encode(SoftBlockCode code, int information, int informationLength)
+ {
+ int mask = (1 << informationLength) - 1;
+
+ for(int codeword: code.getCodewords())
+ {
+ if((codeword & mask) == information)
+ {
+ return codeword;
+ }
+ }
+
+ throw new IllegalStateException("No codeword for information value " + information);
+ }
+
+ /**
+ * Creates a random valid BPTC(196,96) codeword in transmitted (interleaved) bit order.
+ */
+ public static CorrectedBinaryMessage createInterleavedBPTC(Random random)
+ {
+ CorrectedBinaryMessage deinterleaved = new CorrectedBinaryMessage(196);
+
+ for(int row = 0; row < 9; row++)
+ {
+ int information = random.nextInt(1 << 11);
+
+ for(int column = 0; column < 11; column++)
+ {
+ if((information & (1 << column)) != 0)
+ {
+ deinterleaved.set(BPTC_196_96.getIndex(column, row));
+ }
+ }
+ }
+
+ return encodeAndInterleave(deinterleaved);
+ }
+
+ /**
+ * Creates a valid BPTC(196,96) codeword in transmitted (interleaved) bit order that carries the 96-bit payload,
+ * with the reserved bits set to zero.
+ * @param payload of 96 bits
+ */
+ public static CorrectedBinaryMessage createInterleavedBPTC(BinaryMessage payload)
+ {
+ CorrectedBinaryMessage deinterleaved = new CorrectedBinaryMessage(196);
+
+ //Payload bits occupy the row information columns in order, starting at index 4 (see BPTC_196_96.extract())
+ int payloadIndex = 0;
+
+ for(int index = 4; index < BPTC_196_96.MAX_ORIGINAL_INDEX && payloadIndex < 96; index++)
+ {
+ if((index % BPTC_196_96.COLUMN_COUNT) >= 1 && (index % BPTC_196_96.COLUMN_COUNT) <= 11)
+ {
+ if(payload.get(payloadIndex))
+ {
+ deinterleaved.set(index);
+ }
+
+ payloadIndex++;
+ }
+ }
+
+ return encodeAndInterleave(deinterleaved);
+ }
+
+ /**
+ * Encodes the row and column parity for the information bits (rows 0-8, columns 0-10) of the de-interleaved
+ * message and interleaves the result.
+ */
+ private static CorrectedBinaryMessage encodeAndInterleave(CorrectedBinaryMessage deinterleaved)
+ {
+ //Information rows 0-8, each encoded with the row code
+ for(int row = 0; row < 9; row++)
+ {
+ int information = 0;
+
+ for(int column = 0; column < 11; column++)
+ {
+ if(deinterleaved.get(BPTC_196_96.getIndex(column, row)))
+ {
+ information |= (1 << column);
+ }
+ }
+
+ int rowWord = encode(ROW_CODE, information, 11);
+
+ for(int column = 11; column < 15; column++)
+ {
+ if((rowWord & (1 << column)) != 0)
+ {
+ deinterleaved.set(BPTC_196_96.getIndex(column, row));
+ }
+ }
+ }
+
+ //Parity rows 9-12 from the column code
+ for(int column = 0; column < 15; column++)
+ {
+ int information = 0;
+
+ for(int row = 0; row < 9; row++)
+ {
+ if(deinterleaved.get(BPTC_196_96.COLUMN_INDEXES[column][row]))
+ {
+ information |= (1 << row);
+ }
+ }
+
+ int columnWord = encode(COLUMN_CODE, information, 9);
+
+ for(int row = 9; row < 13; row++)
+ {
+ if((columnWord & (1 << row)) != 0)
+ {
+ deinterleaved.set(BPTC_196_96.COLUMN_INDEXES[column][row]);
+ }
+ }
+ }
+
+ //De-interleave maps deinterleaved[x] = interleaved[DEINTERLEAVE[x]], so interleave is the inverse
+ CorrectedBinaryMessage interleaved = new CorrectedBinaryMessage(196);
+
+ for(int x = 0; x < 196; x++)
+ {
+ if(deinterleaved.get(x))
+ {
+ interleaved.set(BPTC_196_96.BPTC_DEINTERLEAVE[x]);
+ }
+ }
+
+ return interleaved;
+ }
+
+ /**
+ * Creates a random 96-bit CSBK payload with a valid CRC-CCITT (80 message bits and 16 CRC bits).
+ */
+ public static BinaryMessage createCSBKPayload(Random random)
+ {
+ BinaryMessage payload = new BinaryMessage(96);
+ int crc = CSBK_CRC_MASK;
+
+ for(int x = 0; x < 80; x++)
+ {
+ if(random.nextBoolean())
+ {
+ payload.set(x);
+ crc ^= CRCDMR.CCITT_80_CHECKSUMS[x];
+ }
+ }
+
+ for(int x = 0; x < 16; x++)
+ {
+ if((crc & (1 << (15 - x))) != 0)
+ {
+ payload.set(80 + x);
+ }
+ }
+
+ return payload;
+ }
+
+ /**
+ * Creates the 20 transmitted slot type bits: Golay(24,12) shortened by 4 leading zero bits, where the 24-bit
+ * message has the color code at bits 4-7, the data type at 8-11, the 11-bit checksum at 12-22 and even parity at 23.
+ */
+ public static boolean[] createSlotType(int colorCode, DataType dataType)
+ {
+ boolean[] golay = new boolean[24];
+ int information = (colorCode << 4) | dataType.getValue();
+ int checksum = 0;
+
+ for(int bit = 0; bit < 8; bit++)
+ {
+ if((information & (1 << (7 - bit))) != 0)
+ {
+ golay[4 + bit] = true;
+ checksum ^= Golay24.CHECKSUMS[4 + bit];
+ }
+ }
+
+ int ones = Integer.bitCount(information);
+
+ for(int bit = 0; bit < 11; bit++)
+ {
+ if((checksum & (1 << (10 - bit))) != 0)
+ {
+ golay[12 + bit] = true;
+ ones++;
+ }
+ }
+
+ golay[23] = ones % 2 != 0;
+
+ boolean[] transmitted = new boolean[20];
+ System.arraycopy(golay, 4, transmitted, 0, 20);
+ return transmitted;
+ }
+
+ /**
+ * Creates a 288-bit DMR base station data burst: CACH (0-23), BPTC payload part 1 (24-121), slot type part 1
+ * (122-131), sync (132-179), slot type part 2 (180-189) and BPTC payload part 2 (190-287).
+ */
+ public static boolean[] createBurst(CorrectedBinaryMessage bptc, int colorCode, DataType dataType)
+ {
+ boolean[] burst = new boolean[288];
+ boolean[] slotType = createSlotType(colorCode, dataType);
+
+ for(int x = 0; x < 98; x++)
+ {
+ burst[24 + x] = bptc.get(x);
+ burst[190 + x] = bptc.get(98 + x);
+ }
+
+ for(int x = 0; x < 10; x++)
+ {
+ burst[122 + x] = slotType[x];
+ burst[180 + x] = slotType[10 + x];
+ }
+
+ Dibit[] sync = SYNC_PATTERN.toDibits();
+
+ for(int x = 0; x < sync.length; x++)
+ {
+ burst[132 + 2 * x] = sync[x].getBit1();
+ burst[133 + 2 * x] = sync[x].getBit2();
+ }
+
+ return burst;
+ }
+
+ /**
+ * Converts the burst bits into ideal soft symbols.
+ */
+ public static float[] toSoftSymbols(boolean[] burst)
+ {
+ float[] softSymbols = new float[BURST_DIBITS];
+
+ for(int x = 0; x < BURST_DIBITS; x++)
+ {
+ int value = (burst[2 * x] ? 2 : 0) + (burst[2 * x + 1] ? 1 : 0);
+ softSymbols[x] = Dibit.fromValue(value).getIdealPhase();
+ }
+
+ return softSymbols;
+ }
+
+ /**
+ * BPTC payload dibit indexes in the burst, excluding the CACH, slot type and sync dibits.
+ */
+ public static List getPayloadDibits()
+ {
+ List payloadDibits = new ArrayList<>();
+
+ for(int x = 12; x < 61; x++)
+ {
+ payloadDibits.add(x);
+ }
+
+ for(int x = 95; x < BURST_DIBITS; x++)
+ {
+ payloadDibits.add(x);
+ }
+
+ return payloadDibits;
+ }
+
+ /**
+ * Moves the soft symbol just across the outer/inner (PI/2) decision boundary, flipping the low reliability
+ * magnitude bit of the symbol decision.
+ * @param softSymbols to modify
+ * @param burst bits to modify to match the new symbol decision
+ * @param dibit index of the symbol to modify
+ * @param margin distance from the decision boundary
+ */
+ public static void addLowReliabilityError(float[] softSymbols, boolean[] burst, int dibit, float margin)
+ {
+ float sign = Math.signum(softSymbols[dibit]);
+ boolean outer = Math.abs(softSymbols[dibit]) > Math.PI / 2;
+ softSymbols[dibit] = sign * ((float)(Math.PI / 2) + (outer ? -margin : margin));
+ burst[2 * dibit + 1] = !burst[2 * dibit + 1];
+ }
+
+ /**
+ * Extracts the transmitted BPTC(196,96) bits from the burst.
+ */
+ public static CorrectedBinaryMessage getBPTC(boolean[] burst)
+ {
+ CorrectedBinaryMessage bptc = new CorrectedBinaryMessage(196);
+
+ for(int x = 0; x < 98; x++)
+ {
+ bptc.set(x, burst[24 + x]);
+ bptc.set(98 + x, burst[190 + x]);
+ }
+
+ return bptc;
+ }
+
+ /**
+ * Feeds the burst soft symbols to the framer via the C4FM input, signals the sync detection on the final sync
+ * dibit, and then flushes the burst through the delay line with filler symbols.
+ * @return messages dispatched by the framer.
+ */
+ public static List frame(float[] softSymbols)
+ {
+ List messages = new ArrayList<>();
+ DMRMessageFramer framer = new DMRMessageFramer(new DMRCrcMaskManager(false));
+ framer.setListener(messages::add);
+ framer.start();
+
+ //The sync pattern ends at dibit 89 (CACH 12 + prefix 54 + sync 24)
+ for(int x = 0; x < DELAY_LINE_DIBITS; x++)
+ {
+ framer.process(Dibit.fromSample(softSymbols[x]), softSymbols[x]);
+ }
+
+ framer.setSyncPattern(SYNC_PATTERN);
+ framer.syncDetected();
+
+ //Remaining burst dibits, followed by filler dibits to eject the full burst from the delay line
+ for(int x = DELAY_LINE_DIBITS; x < BURST_DIBITS + DELAY_LINE_DIBITS; x++)
+ {
+ float softSymbol = x < BURST_DIBITS ? softSymbols[x] : Dibit.D00_PLUS_1.getIdealPhase();
+ framer.process(Dibit.fromSample(softSymbol), softSymbol);
+ }
+
+ return messages;
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/DMRDataMessageFactoryTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRDataMessageFactoryTest.java
new file mode 100644
index 00000000..70fe1abc
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRDataMessageFactoryTest.java
@@ -0,0 +1,160 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr;
+
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.message.IMessage;
+import io.github.dsheirer.module.decode.dmr.bptc.BPTC_196_96;
+import io.github.dsheirer.module.decode.dmr.message.CACH;
+import io.github.dsheirer.module.decode.dmr.message.DMRMessageFactory;
+import io.github.dsheirer.module.decode.dmr.message.data.csbk.CSBKMessage;
+import io.github.dsheirer.module.decode.dmr.message.type.DataType;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.SYNC_PATTERN;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.addLowReliabilityError;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.createBurst;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.createCSBKPayload;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.createInterleavedBPTC;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.frame;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.getBPTC;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.getPayloadDibits;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.toSoftSymbols;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for the DMR data message factory.
+ */
+public class DMRDataMessageFactoryTest
+{
+ private static final int COLOR_CODE = 3;
+ private static final int ERROR_COUNT = 12;
+ //Soft symbol distance from the +/-3 and +/-1 decision boundary (PI/2) for low reliability symbol errors.
+ private static final float LOW_RELIABILITY_MARGIN = 0.05f;
+
+ /**
+ * Finds the CSBK message in the list of messages
+ */
+ private static CSBKMessage getCSBK(List messages)
+ {
+ CSBKMessage csbk = null;
+
+ for(IMessage message: messages)
+ {
+ if(message instanceof CSBKMessage csbkMessage)
+ {
+ csbk = csbkMessage;
+ }
+ }
+
+ assertNotNull(csbk, "Expected a CSBK message: " + messages);
+ return csbk;
+ }
+
+ /**
+ * Test: an error free CSBK burst produces a valid CSBK message with the expected payload.
+ */
+ @Test
+ void testValidCSBK()
+ {
+ Random random = new Random(1);
+ CorrectedBinaryMessage bptc = createInterleavedBPTC(createCSBKPayload(random));
+ CSBKMessage csbk = getCSBK(frame(toSoftSymbols(createBurst(bptc, COLOR_CODE, DataType.CSBK))));
+ assertTrue(csbk.isValid());
+ assertEquals(BPTC_196_96.extract(bptc).toString(), csbk.getMessage().toString());
+ }
+
+ /**
+ * Test: when the hard decision decoder converges on an incorrect codeword (miscorrection) and the CSBK fails the
+ * CRC check, the factory retries with soft decision decoding, which recovers the transmitted CSBK.
+ *
+ * Note: some miscorrections differ from the transmitted codeword by a single CRC protected bit, which the
+ * CSBK CRC check corrects. Those trials are verified to have the correct CRC protected content.
+ */
+ @Test
+ void testSoftRetryAfterHardMiscorrection()
+ {
+ List payloadDibits = getPayloadDibits();
+ DMRMessageFactory factory = new DMRMessageFactory(new DMRCrcMaskManager(false));
+ int crcFailures = 0;
+
+ for(int trial = 0; trial < 3000 && crcFailures < 5; trial++)
+ {
+ Random random = new Random(1000 + trial);
+ CorrectedBinaryMessage bptc = createInterleavedBPTC(createCSBKPayload(random));
+ CorrectedBinaryMessage expected = BPTC_196_96.extract(bptc);
+ boolean[] burst = createBurst(bptc, COLOR_CODE, DataType.CSBK);
+ float[] softSymbols = toSoftSymbols(burst);
+ Set errors = new HashSet<>();
+
+ while(errors.size() < ERROR_COUNT)
+ {
+ errors.add(payloadDibits.get(random.nextInt(payloadDibits.size())));
+ }
+
+ for(int error: errors)
+ {
+ addLowReliabilityError(softSymbols, burst, error, LOW_RELIABILITY_MARGIN);
+ }
+
+ //Only evaluate trials where the hard decision decoder reports success with an incorrect codeword
+ CorrectedBinaryMessage hard = BPTC_196_96.extract(getBPTC(burst));
+
+ if(hard.getCorrectedBitCount() < 0 || hard.toString().equals(expected.toString()))
+ {
+ continue;
+ }
+
+ //Without soft bits, the factory creates the CSBK from the miscorrected payload
+ CorrectedBinaryMessage burstMessage = new CorrectedBinaryMessage(288);
+
+ for(int x = 0; x < 288; x++)
+ {
+ burstMessage.set(x, burst[x]);
+ }
+
+ IMessage hardMessage = factory.create(SYNC_PATTERN, burstMessage, null, CACH.getCACH(burstMessage), 0L, 1);
+ assertTrue(hardMessage instanceof CSBKMessage, "Trial " + trial);
+
+ if(hardMessage.isValid())
+ {
+ //The CRC check corrected the miscorrected payload bit, so the CRC protected content must be correct
+ assertEquals(expected.getSubMessage(0, 96).toString(),
+ ((CSBKMessage)hardMessage).getMessage().getSubMessage(0, 96).toString(), "Trial " + trial);
+ continue;
+ }
+
+ crcFailures++;
+
+ //With soft symbols, the factory retries with soft decision decoding and recovers the transmitted CSBK
+ CSBKMessage csbk = getCSBK(frame(softSymbols));
+ assertTrue(csbk.isValid(), "Trial " + trial);
+ assertEquals(expected.toString(), csbk.getMessage().toString(), "Trial " + trial);
+ }
+
+ assertEquals(5, crcFailures, "Expected to find 5 hard decision miscorrections that fail the CRC check");
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java
new file mode 100644
index 00000000..6aec55ad
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/DMRMessageFramerTest.java
@@ -0,0 +1,144 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr;
+
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.message.IMessage;
+import io.github.dsheirer.module.decode.dmr.bptc.BPTC_196_96;
+import io.github.dsheirer.module.decode.dmr.message.data.IDLEMessage;
+import io.github.dsheirer.module.decode.dmr.message.type.DataType;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.addLowReliabilityError;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.createBurst;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.createInterleavedBPTC;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.frame;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.getBPTC;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.getPayloadDibits;
+import static io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility.toSoftSymbols;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for the DMR message framer C4FM symbol stream input, where the framer maintains the burst delay lines
+ * for the dibits and soft symbols and the sync detection is signalled on the final dibit of the sync pattern.
+ */
+public class DMRMessageFramerTest
+{
+ private static final int COLOR_CODE = 5;
+ //Soft symbol distance from the +/-3 and +/-1 decision boundary (PI/2) for low reliability symbol errors.
+ private static final float LOW_RELIABILITY_MARGIN = 0.05f;
+
+ /**
+ * Finds the IDLE message dispatched by the framer
+ */
+ private static IDLEMessage getIdleMessage(List messages)
+ {
+ IDLEMessage idle = null;
+
+ for(IMessage message: messages)
+ {
+ if(message instanceof IDLEMessage idleMessage)
+ {
+ idle = idleMessage;
+ }
+ }
+
+ assertNotNull(idle, "Framer did not dispatch an IDLE message: " + messages);
+ return idle;
+ }
+
+ /**
+ * Test: a burst fed through the C4FM input with the sync detection signalled on the final sync dibit is framed
+ * with the correct alignment so that the slot type and BPTC payload decode without errors.
+ */
+ @Test
+ void testBurstAlignment()
+ {
+ Random random = new Random(1);
+ CorrectedBinaryMessage bptc = createInterleavedBPTC(random);
+ CorrectedBinaryMessage expected = BPTC_196_96.extract(bptc);
+ assertEquals(0, expected.getCorrectedBitCount());
+
+ IDLEMessage idle = getIdleMessage(frame(toSoftSymbols(createBurst(bptc, COLOR_CODE, DataType.SLOT_IDLE))));
+ assertTrue(idle.isValid());
+ assertEquals(COLOR_CODE, idle.getSlotType().getColorCode());
+ assertEquals(DataType.SLOT_IDLE, idle.getSlotType().getDataType());
+ assertEquals(expected.toString(), idle.getMessage().toString());
+ assertEquals(0, idle.getMessage().getCorrectedBitCount());
+ }
+
+ /**
+ * Test: low reliability symbol errors in the BPTC payload that exceed the hard decision decoder capability are
+ * corrected by soft decision decoding, which requires the soft symbols to remain aligned with the dibits through
+ * the framer delay lines.
+ *
+ * Note: for IDLE messages (no CRC), soft decision decoding is only applied when the hard decision decoder fails,
+ * so trials where the hard decision decoder reports success (correctly, or with a miscorrection) don't exercise
+ * the soft symbols and are not evaluated.
+ */
+ @Test
+ void testSoftSymbolAlignment()
+ {
+ List payloadDibits = getPayloadDibits();
+ int hardFailures = 0;
+
+ for(int trial = 0; trial < 40; trial++)
+ {
+ Random random = new Random(100 + trial);
+ CorrectedBinaryMessage bptc = createInterleavedBPTC(random);
+ CorrectedBinaryMessage expected = BPTC_196_96.extract(bptc);
+ boolean[] burst = createBurst(bptc, COLOR_CODE, DataType.SLOT_IDLE);
+ float[] softSymbols = toSoftSymbols(burst);
+
+ //Add 10 low reliability payload symbol errors
+ Set errors = new HashSet<>();
+
+ while(errors.size() < 10)
+ {
+ errors.add(payloadDibits.get(random.nextInt(payloadDibits.size())));
+ }
+
+ for(int error: errors)
+ {
+ addLowReliabilityError(softSymbols, burst, error, LOW_RELIABILITY_MARGIN);
+ }
+
+ //Only evaluate trials where the hard decision decoder fails
+ if(BPTC_196_96.extract(getBPTC(burst)).getCorrectedBitCount() >= 0)
+ {
+ continue;
+ }
+
+ hardFailures++;
+ IDLEMessage idle = getIdleMessage(frame(softSymbols));
+ assertTrue(idle.isValid(), "Trial " + trial);
+ assertEquals(expected.toString(), idle.getMessage().toString(), "Trial " + trial);
+ }
+
+ //Sanity check that the soft decision decoder was exercised
+ assertTrue(hardFailures >= 5, "Expected at least 5 hard decision failures, but found " + hardFailures);
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java
new file mode 100644
index 00000000..6255b75b
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_128_77SoftTest.java
@@ -0,0 +1,238 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr.bptc;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.edac.Hamming16;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
+import io.github.dsheirer.module.decode.dmr.message.voice.EMB;
+import java.util.HashSet;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for DMR soft decision decoding of the embedded signalling BPTC(128,77) product code and the EMB.
+ */
+public class BPTC_128_77SoftTest
+{
+ private static final int[] WORD = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
+ private static final SoftBlockCode ROW_CODE = SoftBlockCode.fromCodewordTest(16, word -> {
+ BinaryMessage message = new BinaryMessage(16);
+
+ for(int i = 0; i < 16; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return Hamming16.getSyndrome(message, 0) == 0;
+ });
+
+ /**
+ * Creates a random valid (de-interleaved) BPTC(128,77) product codeword: 7 random Hamming(16,11,4) rows followed
+ * by the column parity row.
+ */
+ private static CorrectedBinaryMessage createCodeword(Random random)
+ {
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(128);
+ int[] codewords = ROW_CODE.getCodewords();
+ int parityRow = 0;
+
+ for(int row = 0; row < 7; row++)
+ {
+ int rowWord = codewords[random.nextInt(codewords.length)];
+ parityRow ^= rowWord;
+
+ for(int column = 0; column < 16; column++)
+ {
+ if((rowWord & (1 << column)) != 0)
+ {
+ message.set(row * 16 + column);
+ }
+ }
+ }
+
+ for(int column = 0; column < 16; column++)
+ {
+ if((parityRow & (1 << column)) != 0)
+ {
+ message.set(7 * 16 + column);
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Test: the row code is Hamming(16,11) with 2048 codewords and the generated product codewords are valid for the
+ * hard decision decoder.
+ */
+ @Test
+ void testValidCodeword()
+ {
+ assertEquals(2048, ROW_CODE.getCodewords().length);
+
+ BPTC_128_77 bptc = new BPTC_128_77();
+ CorrectedBinaryMessage codeword = createCodeword(new Random(1));
+ assertTrue(bptc.getRowErrors(codeword).isEmpty());
+ assertTrue(bptc.getColumnErrors(codeword).isEmpty());
+
+ float[] softBits = new float[128];
+
+ for(int x = 0; x < 128; x++)
+ {
+ softBits[x] = codeword.get(x) ? 1.0f : -1.0f;
+ }
+
+ CorrectedBinaryMessage decoded = bptc.decodeSoft(softBits);
+ assertNotNull(decoded);
+ assertEquals(codeword, decoded);
+ assertEquals(0, decoded.getCorrectedBitCount());
+ }
+
+ /**
+ * Test: soft decoding corrects 6 low reliability bit errors placed randomly across the product codeword.
+ */
+ @Test
+ void testSoftDecodeRandomErrors()
+ {
+ Random random = new Random(2);
+ BPTC_128_77 bptc = new BPTC_128_77();
+ int hardFailures = 0;
+
+ for(int trial = 0; trial < 200; trial++)
+ {
+ CorrectedBinaryMessage codeword = createCodeword(random);
+ float[] softBits = new float[128];
+
+ for(int x = 0; x < 128; x++)
+ {
+ float magnitude = 0.6f + 0.8f * random.nextFloat();
+ softBits[x] = codeword.get(x) ? magnitude : -magnitude;
+ }
+
+ Set errors = new HashSet<>();
+
+ while(errors.size() < 6)
+ {
+ errors.add(random.nextInt(128));
+ }
+
+ CorrectedBinaryMessage hard = new CorrectedBinaryMessage(codeword);
+
+ for(int error: errors)
+ {
+ softBits[error] = -0.3f * random.nextFloat() * Math.signum(softBits[error]);
+ hard.flip(error);
+ }
+
+ if(!bptc.correct(hard) || !hard.equals(codeword))
+ {
+ hardFailures++;
+ }
+
+ CorrectedBinaryMessage decoded = bptc.decodeSoft(softBits);
+ assertNotNull(decoded, "Soft decode failed on trial " + trial);
+ assertEquals(codeword, decoded, "Soft decode miscorrected on trial " + trial);
+ assertEquals(6, decoded.getCorrectedBitCount());
+ }
+
+ //Sanity check that this scenario exceeds the hard decision decoder capability
+ assertTrue(hardFailures > 0, "Expected hard decision failures");
+ }
+
+ /**
+ * Test: EMB soft decoding corrects 4 low reliability bit errors (beyond the hard decoder's 3-bit capability) and
+ * flags the result invalid since it exceeds the soft decision validity threshold. Also verifies that a single
+ * low reliability bit error is corrected and valid.
+ */
+ @Test
+ void testEMBSoftDecode()
+ {
+ Random random = new Random(3);
+ CorrectedBinaryMessage reference = new CorrectedBinaryMessage(16);
+
+ for(int trial = 0; trial < 100; trial++)
+ {
+ //Find a valid word by hard decoding a random word with no more than 1 bit error after correction
+ int word;
+ EMB referenceEMB;
+
+ do
+ {
+ word = random.nextInt(1 << 16);
+ reference.setInt(word, WORD);
+ referenceEMB = new EMB(new CorrectedBinaryMessage(reference));
+ }
+ while(!referenceEMB.isValid());
+
+ //Recover the corrected valid word
+ CorrectedBinaryMessage corrected = new CorrectedBinaryMessage(reference);
+ new EMB(corrected);
+ int validWord = corrected.getInt(0, 15);
+
+ for(int errorCount: new int[]{1, 4})
+ {
+ float[] softBits = new float[16];
+ CorrectedBinaryMessage received = new CorrectedBinaryMessage(16);
+ received.setInt(validWord, WORD);
+
+ for(int x = 0; x < 16; x++)
+ {
+ softBits[x] = received.get(x) ? 1.0f : -1.0f;
+ }
+
+ Set errors = new HashSet<>();
+
+ while(errors.size() < errorCount)
+ {
+ errors.add(random.nextInt(16));
+ }
+
+ for(int error: errors)
+ {
+ received.flip(error);
+ softBits[error] = -0.2f * softBits[error];
+ }
+
+ EMB emb = new EMB(received, softBits);
+ assertEquals(validWord, received.getInt(0, 15), "Trial " + trial + " errors " + errorCount);
+
+ if(errorCount == 1)
+ {
+ assertTrue(emb.isValid());
+ }
+ else
+ {
+ assertFalse(emb.isValid());
+ }
+ }
+ }
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java
new file mode 100644
index 00000000..fdebdd99
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96SoftTest.java
@@ -0,0 +1,202 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr.bptc;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.edac.Hamming13;
+import io.github.dsheirer.edac.Hamming15;
+import io.github.dsheirer.edac.soft.SoftBlockCode;
+import java.util.HashSet;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for DMR soft decision decoding of the BPTC(196,96) product code.
+ */
+public class BPTC_196_96SoftTest
+{
+ private static final SoftBlockCode ROW_CODE = SoftBlockCode.fromCodewordTest(15,
+ word -> Hamming15.getSyndrome(toMessage(word, 15), 0) == 0);
+ private static final SoftBlockCode COLUMN_CODE = SoftBlockCode.fromCodewordTest(13,
+ word -> Hamming13.getSyndrome(toMessage(word, 13), new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}) == 0);
+
+ private static BinaryMessage toMessage(int word, int length)
+ {
+ BinaryMessage message = new BinaryMessage(length);
+
+ for(int i = 0; i < length; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Finds the systematic codeword whose information bits (the low bits) match the information value.
+ */
+ private static int encode(SoftBlockCode code, int information, int informationLength)
+ {
+ int mask = (1 << informationLength) - 1;
+
+ for(int codeword: code.getCodewords())
+ {
+ if((codeword & mask) == information)
+ {
+ return codeword;
+ }
+ }
+
+ throw new IllegalStateException("No codeword for information value " + information);
+ }
+
+ /**
+ * Creates a random valid (de-interleaved) BPTC(196,96) product codeword.
+ */
+ private static CorrectedBinaryMessage createCodeword(Random random)
+ {
+ CorrectedBinaryMessage message = new CorrectedBinaryMessage(196);
+
+ //Information rows 0-8, each encoded with the row code
+ for(int row = 0; row < 9; row++)
+ {
+ int rowWord = encode(ROW_CODE, random.nextInt(1 << 11), 11);
+
+ for(int x = 0; x < 15; x++)
+ {
+ if((rowWord & (1 << x)) != 0)
+ {
+ message.set(BPTC_196_96.getIndex(x, row));
+ }
+ }
+ }
+
+ //Parity rows 9-12 from the column code
+ for(int column = 0; column < 15; column++)
+ {
+ int information = 0;
+
+ for(int row = 0; row < 9; row++)
+ {
+ if(message.get(BPTC_196_96.COLUMN_INDEXES[column][row]))
+ {
+ information |= (1 << row);
+ }
+ }
+
+ int columnWord = encode(COLUMN_CODE, information, 9);
+
+ for(int row = 9; row < 13; row++)
+ {
+ if((columnWord & (1 << row)) != 0)
+ {
+ message.set(BPTC_196_96.COLUMN_INDEXES[column][row]);
+ }
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Test: valid product codewords have no row or column errors and soft decoding of an error free codeword
+ * returns the codeword.
+ */
+ @Test
+ void testValidCodeword()
+ {
+ Random random = new Random(2);
+ CorrectedBinaryMessage codeword = createCodeword(random);
+ CorrectedBinaryMessage copy = new CorrectedBinaryMessage(codeword);
+ assertTrue(BPTC_196_96.correct(copy, true));
+ assertEquals(codeword, copy);
+
+ float[] softBits = new float[196];
+
+ for(int x = 0; x < 196; x++)
+ {
+ softBits[x] = codeword.get(x) ? 1.0f : -1.0f;
+ }
+
+ CorrectedBinaryMessage decoded = BPTC_196_96.decodeSoft(softBits);
+ assertNotNull(decoded);
+ assertEquals(codeword, decoded);
+ assertEquals(0, decoded.getCorrectedBitCount());
+ }
+
+ /**
+ * Test: soft decoding corrects 10 low reliability bit errors placed randomly across the product codeword.
+ */
+ @Test
+ void testSoftDecodeRandomErrors()
+ {
+ Random random = new Random(3);
+ int hardFailures = 0;
+
+ for(int trial = 0; trial < 200; trial++)
+ {
+ CorrectedBinaryMessage codeword = createCodeword(random);
+ float[] softBits = new float[196];
+
+ for(int x = 0; x < 196; x++)
+ {
+ float magnitude = 0.6f + 0.8f * random.nextFloat();
+ softBits[x] = codeword.get(x) ? magnitude : -magnitude;
+ }
+
+ Set errors = new HashSet<>();
+
+ while(errors.size() < 10)
+ {
+ errors.add(1 + random.nextInt(195));
+ }
+
+ CorrectedBinaryMessage hard = new CorrectedBinaryMessage(codeword);
+
+ for(int error: errors)
+ {
+ softBits[error] = -0.3f * random.nextFloat() * Math.signum(softBits[error]);
+ hard.flip(error);
+ }
+
+ if(!BPTC_196_96.correct(hard, true) || !hard.equals(codeword))
+ {
+ hardFailures++;
+ }
+
+ CorrectedBinaryMessage decoded = BPTC_196_96.decodeSoft(softBits);
+ assertNotNull(decoded, "Soft decode failed on trial " + trial);
+ assertEquals(codeword, decoded, "Soft decode miscorrected on trial " + trial);
+ assertEquals(10, decoded.getCorrectedBitCount());
+ }
+
+ //Sanity check that this scenario exceeds the hard decision decoder capability
+ assertTrue(hardFailures > 0, "Expected hard decision failures");
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96Test.java b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96Test.java
new file mode 100644
index 00000000..08820b37
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/bptc/BPTC_196_96Test.java
@@ -0,0 +1,115 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr.bptc;
+
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.module.decode.dmr.DMRBurstTestUtility;
+import java.util.HashSet;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for the DMR BPTC(196,96) hard decision decoder.
+ */
+public class BPTC_196_96Test
+{
+ /**
+ * Test: when the hard decision decoder recovers the transmitted codeword, the corrected bit count equals the
+ * number of bit errors. The turbo decoder's incremental accounting previously counted some corrections twice
+ * (ie reporting 13-14 corrected bits for 10 bit errors).
+ */
+ @Test
+ void testCorrectedBitCount()
+ {
+ Random random = new Random(1);
+ int[] correctDecodes = new int[9];
+
+ for(int errorCount = 1; errorCount <= 8; errorCount++)
+ {
+ for(int trial = 0; trial < 200; trial++)
+ {
+ CorrectedBinaryMessage bptc = DMRBurstTestUtility.createInterleavedBPTC(random);
+ CorrectedBinaryMessage expected = BPTC_196_96.extract(bptc);
+ CorrectedBinaryMessage received = new CorrectedBinaryMessage(bptc);
+
+ //Bit 0 is a pad bit that isn't protected by the row or column codes
+ Set errors = new HashSet<>();
+
+ while(errors.size() < errorCount)
+ {
+ errors.add(1 + random.nextInt(195));
+ }
+
+ for(int error: errors)
+ {
+ received.flip(error);
+ }
+
+ CorrectedBinaryMessage decoded = BPTC_196_96.extract(received);
+
+ if(decoded.getCorrectedBitCount() >= 0 && decoded.toString().equals(expected.toString()))
+ {
+ correctDecodes[errorCount]++;
+ assertEquals(errorCount, decoded.getCorrectedBitCount(), "Errors " + errorCount + " trial " + trial);
+ }
+ }
+ }
+
+ //Up to 2 bit errors are always corrected
+ assertEquals(200, correctDecodes[1]);
+ assertEquals(200, correctDecodes[2]);
+
+ for(int errorCount = 3; errorCount <= 8; errorCount++)
+ {
+ assertTrue(correctDecodes[errorCount] > 100, "Expected most decodes to be correct for " + errorCount +
+ " errors, but found " + correctDecodes[errorCount]);
+ }
+ }
+
+ /**
+ * Test: soft decision extraction of an error free codeword matches the hard decision extraction.
+ */
+ @Test
+ void testExtractSoft()
+ {
+ Random random = new Random(2);
+
+ for(int trial = 0; trial < 20; trial++)
+ {
+ CorrectedBinaryMessage bptc = DMRBurstTestUtility.createInterleavedBPTC(random);
+ float[] softBits = new float[196];
+
+ for(int x = 0; x < 196; x++)
+ {
+ softBits[x] = bptc.get(x) ? 1.0f : -1.0f;
+ }
+
+ CorrectedBinaryMessage soft = BPTC_196_96.extractSoft(softBits);
+ assertNotNull(soft);
+ assertEquals(BPTC_196_96.extract(bptc).toString(), soft.toString());
+ assertEquals(0, soft.getCorrectedBitCount());
+ }
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/message/CACHTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/message/CACHTest.java
new file mode 100644
index 00000000..2b0ed49d
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/message/CACHTest.java
@@ -0,0 +1,84 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr.message;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import java.util.Random;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * JUnit tests for the DMR Common Announcement Channel (CACH).
+ */
+public class CACHTest
+{
+ /**
+ * Test: the CACH payload soft bits are de-interleaved consistently with the payload bits, so that each soft bit
+ * corresponds to the payload bit at the same position.
+ */
+ @Test
+ void testPayloadSoftBits()
+ {
+ Random random = new Random(1);
+
+ for(int trial = 0; trial < 50; trial++)
+ {
+ CorrectedBinaryMessage burst = new CorrectedBinaryMessage(288);
+ float[] softBits = new float[288];
+
+ for(int x = 0; x < 288; x++)
+ {
+ if(random.nextBoolean())
+ {
+ burst.set(x);
+ }
+
+ //Unique magnitude per bit position so that a position mismatch can't go undetected
+ float magnitude = 1.0f + x / 1000.0f;
+ softBits[x] = burst.get(x) ? magnitude : -magnitude;
+ }
+
+ CACH cach = CACH.getCACH(burst, softBits);
+ BinaryMessage payload = cach.getPayload();
+ float[] payloadSoftBits = cach.getPayloadSoftBits();
+ assertNotNull(payloadSoftBits);
+ assertEquals(17, payloadSoftBits.length);
+
+ for(int x = 0; x < 17; x++)
+ {
+ assertEquals(payload.get(x), payloadSoftBits[x] > 0, "Trial " + trial + " payload bit " + x);
+ }
+ }
+ }
+
+ /**
+ * Test: payload soft bits are not available when the burst soft bits are not provided.
+ */
+ @Test
+ void testNoSoftBits()
+ {
+ assertNull(CACH.getCACH(new CorrectedBinaryMessage(288)).getPayloadSoftBits());
+ assertNull(CACH.getCACH(new CorrectedBinaryMessage(288), null).getPayloadSoftBits());
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssemblerTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssemblerTest.java
new file mode 100644
index 00000000..67379530
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/message/data/lc/shorty/SLCAssemblerTest.java
@@ -0,0 +1,328 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2014-2026 Dennis Sheirer
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+package io.github.dsheirer.module.decode.dmr.message.data.lc.shorty;
+
+import io.github.dsheirer.bits.BinaryMessage;
+import io.github.dsheirer.edac.CRCDMR;
+import io.github.dsheirer.edac.Hamming17;
+import io.github.dsheirer.module.decode.dmr.message.type.LCSS;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * JUnit tests for the short link control assembler, which reassembles 4 CACH fragments into a BPTC(68,36) protected
+ * short link control message with a CRC-8 check.
+ */
+public class SLCAssemblerTest
+{
+ private static final int ROW_LENGTH = 17;
+ private static final int ROW_INFORMATION_LENGTH = 12;
+ private static final List ROW_CODEWORDS = new ArrayList<>();
+
+ static
+ {
+ //Hamming(17,12) codewords where bit i of the codeword is message bit i
+ for(int word = 0; word < (1 << ROW_LENGTH); word++)
+ {
+ BinaryMessage message = new BinaryMessage(ROW_LENGTH);
+
+ for(int i = 0; i < ROW_LENGTH; i++)
+ {
+ if((word & (1 << i)) != 0)
+ {
+ message.set(i);
+ }
+ }
+
+ if(Hamming17.getSyndrome(message, 0) == 0)
+ {
+ ROW_CODEWORDS.add(word);
+ }
+ }
+ }
+
+ /**
+ * Finds the systematic row codeword whose information bits (the low 12 bits) match the information value.
+ */
+ private static int encodeRow(int information)
+ {
+ for(int codeword: ROW_CODEWORDS)
+ {
+ if((codeword & ((1 << ROW_INFORMATION_LENGTH) - 1)) == information)
+ {
+ return codeword;
+ }
+ }
+
+ throw new IllegalStateException("No codeword for information value " + information);
+ }
+
+ /**
+ * Creates a 36-bit short link control payload of 28 random data bits followed by the CRC-8.
+ * @param random for the data bits
+ * @param validCRC true to append the correct CRC-8, or false to append a CRC-8 with one bit inverted.
+ */
+ private static BinaryMessage createPayload(Random random, boolean validCRC)
+ {
+ BinaryMessage payload = new BinaryMessage(36);
+
+ for(int x = 0; x < 28; x++)
+ {
+ if(random.nextBoolean())
+ {
+ payload.set(x);
+ }
+ }
+
+ int crc = CRCDMR.crc8(payload, 28);
+
+ if(!validCRC)
+ {
+ crc ^= 1;
+ }
+
+ for(int x = 0; x < 8; x++)
+ {
+ if((crc & (1 << (7 - x))) != 0)
+ {
+ payload.set(28 + x);
+ }
+ }
+
+ return payload;
+ }
+
+ /**
+ * Encodes the 36-bit payload as a BPTC(68,36) message in transmitted (interleaved) bit order: 3 Hamming(17,12)
+ * rows carrying 12 payload bits each, followed by an even column parity row.
+ */
+ private static BinaryMessage encode(BinaryMessage payload)
+ {
+ BinaryMessage deinterleaved = new BinaryMessage(68);
+ int parityRow = 0;
+
+ for(int row = 0; row < 4; row++)
+ {
+ int rowWord;
+
+ if(row < 3)
+ {
+ int information = 0;
+
+ for(int column = 0; column < ROW_INFORMATION_LENGTH; column++)
+ {
+ if(payload.get(row * ROW_INFORMATION_LENGTH + column))
+ {
+ information |= (1 << column);
+ }
+ }
+
+ rowWord = encodeRow(information);
+ parityRow ^= rowWord;
+ }
+ else
+ {
+ rowWord = parityRow;
+ }
+
+ for(int column = 0; column < ROW_LENGTH; column++)
+ {
+ if((rowWord & (1 << column)) != 0)
+ {
+ deinterleaved.set(row * ROW_LENGTH + column);
+ }
+ }
+ }
+
+ //De-interleave maps deinterleaved[(index * 17) % 67] = interleaved[index] (index 67 is not interleaved)
+ BinaryMessage interleaved = new BinaryMessage(68);
+
+ for(int index = 0; index < 67; index++)
+ {
+ if(deinterleaved.get((index * 17) % 67))
+ {
+ interleaved.set(index);
+ }
+ }
+
+ if(deinterleaved.get(67))
+ {
+ interleaved.set(67);
+ }
+
+ return interleaved;
+ }
+
+ /**
+ * Splits the interleaved message into 4 CACH fragments and processes them through the assembler.
+ * @return the short link control message dispatched with the last fragment.
+ */
+ private static ShortLCMessage assemble(BinaryMessage interleaved)
+ {
+ return assemble(interleaved, null);
+ }
+
+ /**
+ * Splits the interleaved message and optional soft bits into 4 CACH fragments and processes them through the
+ * assembler.
+ * @param interleaved message
+ * @param softBits for the interleaved message, or null.
+ * @return the short link control message dispatched with the last fragment.
+ */
+ private static ShortLCMessage assemble(BinaryMessage interleaved, float[] softBits)
+ {
+ SLCAssembler assembler = new SLCAssembler();
+ LCSS[] lcss = new LCSS[]{LCSS.FIRST_FRAGMENT, LCSS.CONTINUATION_FRAGMENT, LCSS.CONTINUATION_FRAGMENT,
+ LCSS.LAST_FRAGMENT};
+ ShortLCMessage message = null;
+
+ for(int fragment = 0; fragment < 4; fragment++)
+ {
+ BinaryMessage cachPayload = interleaved.getSubMessage(fragment * 17, fragment * 17 + 17);
+ float[] softFragment = softBits != null ? Arrays.copyOfRange(softBits, fragment * 17, fragment * 17 + 17) :
+ null;
+ message = assembler.process(lcss[fragment], cachPayload, softFragment, 0L);
+
+ if(fragment < 3)
+ {
+ assertNull(message, "Message dispatched before the last fragment");
+ }
+ }
+
+ assertNotNull(message, "Message not dispatched with the last fragment");
+ return message;
+ }
+
+ /**
+ * Test: an error free short link control message with a valid CRC is valid and carries the payload.
+ */
+ @Test
+ void testValidMessage()
+ {
+ Random random = new Random(1);
+
+ for(int trial = 0; trial < 20; trial++)
+ {
+ BinaryMessage payload = createPayload(random, true);
+ ShortLCMessage message = assemble(encode(payload));
+ assertTrue(message.isValid(), "Trial " + trial);
+ assertEquals(payload.toString(), message.getMessage().getSubMessage(0, 36).toString(), "Trial " + trial);
+ }
+ }
+
+ /**
+ * Test: a short link control message with a single bit transmission error is corrected and valid.
+ */
+ @Test
+ void testCorrectedMessage()
+ {
+ Random random = new Random(2);
+
+ for(int trial = 0; trial < 68; trial++)
+ {
+ BinaryMessage payload = createPayload(random, true);
+ BinaryMessage interleaved = encode(payload);
+ interleaved.flip(trial);
+ ShortLCMessage message = assemble(interleaved);
+ assertTrue(message.isValid(), "Bit error at " + trial);
+ assertEquals(payload.toString(), message.getMessage().getSubMessage(0, 36).toString(),
+ "Bit error at " + trial);
+ }
+ }
+
+ /**
+ * Test: a short link control message that passes BPTC error correction but fails the CRC-8 check is invalid.
+ * This occurs when the BPTC decoder converges on an incorrect codeword (miscorrection). The assembler previously
+ * overwrote the CRC-8 result with the BPTC result, reporting these messages as valid.
+ */
+ @Test
+ void testInvalidCRC()
+ {
+ Random random = new Random(3);
+
+ for(int trial = 0; trial < 20; trial++)
+ {
+ ShortLCMessage message = assemble(encode(createPayload(random, false)));
+ assertEquals(0, message.getMessage().getCorrectedBitCount(), "Trial " + trial);
+ assertFalse(message.isValid(), "Trial " + trial);
+ }
+ }
+
+ /**
+ * Test: low reliability bit errors that defeat the hard decision decoder (decode failure, or a miscorrection that
+ * fails the CRC-8 check) are corrected by soft decision decoding when the fragment soft bits are provided.
+ */
+ @Test
+ void testSoftDecision()
+ {
+ int hardFailures = 0;
+
+ for(int trial = 0; trial < 200 && hardFailures < 10; trial++)
+ {
+ Random random = new Random(100 + trial);
+ BinaryMessage payload = createPayload(random, true);
+ BinaryMessage interleaved = encode(payload);
+ float[] softBits = new float[68];
+
+ for(int x = 0; x < 68; x++)
+ {
+ softBits[x] = interleaved.get(x) ? 1.0f : -1.0f;
+ }
+
+ //Add 6 low reliability bit errors
+ Set errors = new HashSet<>();
+
+ while(errors.size() < 6)
+ {
+ errors.add(random.nextInt(68));
+ }
+
+ for(int error: errors)
+ {
+ interleaved.flip(error);
+ softBits[error] = -0.2f * softBits[error];
+ }
+
+ //Only evaluate trials where the hard decision result is invalid
+ if(assemble(interleaved).isValid())
+ {
+ continue;
+ }
+
+ hardFailures++;
+ ShortLCMessage message = assemble(interleaved, softBits);
+ assertTrue(message.isValid(), "Trial " + trial);
+ assertEquals(payload.toString(), message.getMessage().getSubMessage(0, 36).toString(), "Trial " + trial);
+ }
+
+ assertEquals(10, hardFailures, "Expected to find 10 hard decision failures");
+ }
+}