diff --git a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMEqualizer.java b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMEqualizer.java index 78b04a22..a4181d3d 100644 --- a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMEqualizer.java +++ b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMEqualizer.java @@ -179,6 +179,27 @@ public class C4FMEqualizer mSymbolsUntilTraining = mDelaySymbolCount; } + /** + * Notifies the equalizer that a sync pattern is detected and updates the sync pattern used for training. This + * supports protocols (ie DMR) that employ multiple sync patterns of the same length. + * @param syncSymbols for the detected sync pattern, which must be the same length as the constructor sync pattern. + */ + public void syncDetected(float[] syncSymbols) + { + if(syncSymbols.length != mReversedSyncSymbols.length) + { + throw new IllegalArgumentException("Sync symbols length [" + syncSymbols.length + + "] must match the equalizer sync pattern length [" + mReversedSyncSymbols.length + "]"); + } + + for(int x = 0; x < syncSymbols.length; x++) + { + mReversedSyncSymbols[syncSymbols.length - x - 1] = syncSymbols[x]; + } + + syncDetected(); + } + /** * Indicates that the sync patter is lost and the equalizer should reset. */ diff --git a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMMessageFramer.java b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMMessageFramer.java index bf4219c5..57fd5bf9 100644 --- a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMMessageFramer.java +++ b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMMessageFramer.java @@ -7,6 +7,26 @@ import io.github.dsheirer.dsp.symbol.Dibit; */ public abstract class C4FMMessageFramer { + private static final float SOFT_SYMBOL_SCALE = (float)(4.0 / Math.PI); //Radians to symbol units (+/-1, +/-3) + + /** + * 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 + */ + protected 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); + } + /** * Process the symbol * @param symbol in radians diff --git a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java index 6669da76..5a7a8744 100644 --- a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java +++ b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java @@ -22,10 +22,14 @@ package io.github.dsheirer.dsp.c4fm; import io.github.dsheirer.dsp.filter.interpolator.LinearInterpolator; import io.github.dsheirer.dsp.symbol.Dibit; import io.github.dsheirer.dsp.symbol.DibitToByteBufferAssembler; +import io.github.dsheirer.gui.viewer.sync.FMSyncResultsViewer; +import io.github.dsheirer.gui.viewer.sync.ISyncResultsListener; import io.github.dsheirer.module.decode.FeedbackDecoder; import io.github.dsheirer.sample.Listener; import java.nio.ByteBuffer; import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; import org.apache.commons.math3.stat.descriptive.moment.Mean; import org.apache.commons.math3.stat.descriptive.moment.StandardDeviation; @@ -59,6 +63,9 @@ public abstract class C4FMSymbolProcessor private final FeedbackDecoder mFeedbackDecoder; private final C4FMMessageFramer mMessageFramer; private final ISoftC4FMSyncDetector mSyncDetectorLagging; + //Detector (primary or lagging) that triggered the current sync detection. For multi-pattern sync detectors, this + //detector's most recently detected sync pattern is used for optimization, validation and equalizer training. + private ISoftC4FMSyncDetector mActiveSyncDetector; private final C4FMEqualizer mSymbolEqualizer; private static final double SYMBOL_TIMING_GAIN = 0.25; //Clip applied to the Gardner timing error (radians squared) to limit the impact of FM discriminator clicks @@ -68,7 +75,6 @@ public abstract class C4FMSymbolProcessor private static final double IDEAL_OUTER_SYMBOL_PHASE = 3.0 * Math.PI / 4.0; //Allowable gain deviation from the nominal gain, as a fraction of the nominal gain. private static final double SAMPLE_GAIN_TOLERANCE = 0.1; - private ReferenceSyncWaveform mReferenceSyncWaveform; private double mNoiseStandardDeviationThreshold; private double mSamplePoint; protected double mSamplesPerSymbol; @@ -85,13 +91,13 @@ public abstract class C4FMSymbolProcessor protected boolean mSynchronized = false; private boolean mDelayedSyncNotificationPending = false; -// private final boolean mDebugVisualizeSync = true; -// private final boolean mDebugVisualizeSymbols = true; -// private final boolean mDebugLogDetectionScores = false; -// private final int mDebugSymbolStart = 0; -// private ISyncResultsListener mDebugSyncResultsViewer; -// private int mDebugSymbolCounter = 0; -// private int mDebugSampleCounter = 0; + private final boolean mDebugVisualizeSync = false; + private final boolean mDebugVisualizeSymbols = false; + private final boolean mDebugLogDetectionScores = false; + private final int mDebugSymbolStart = 0; + private ISyncResultsListener mDebugSyncResultsViewer; + private int mDebugSymbolCounter = 0; + private int mDebugSampleCounter = 0; /** * Constructs an instance @@ -104,6 +110,7 @@ public abstract class C4FMSymbolProcessor mFeedbackDecoder = feedbackDecoder; mSyncDetector = primary; mSyncDetectorLagging = lagging; + mActiveSyncDetector = primary; mSymbolEqualizer = new C4FMEqualizer(mSyncDetector.getPatternSymbols(), getEqualizerSymbolLength(), getEqualizerTrainingPeriodDurationSymbolCount(), getEqualizerSkipTrainingFrameCount()); mSampleEqualizer = new SampleEqualizer(isDynamicBalanceTrackingRequired()); @@ -209,6 +216,37 @@ public abstract class C4FMSymbolProcessor */ public abstract float getSyncThresholdEqualized(); + /** + * Notification that a sync detection was validated and the correction applied. Subclasses can override this to + * capture protocol specific details (ie the detected sync pattern) from the sync detector that triggered the + * detection. The message framer is notified of the sync detection once the final sync symbol emerges from the + * equalizer delay line, which can be after this method is invoked. + * @param syncDetector that triggered the sync detection. + */ + protected void syncDetected(ISoftC4FMSyncDetector syncDetector) + { + //No-op default implementation + } + + /** + * Indicates if a sync detection is allowed at the current symbol. Protocols with fixed length framing (ie DMR) + * can override this to only allow sync detections at the expected frame boundaries once synchronized, to prevent + * false sync detections from the frame payload. Default implementation always allows sync detection. + * @param symbolsSinceLastSync count of symbols since the final symbol of the previous sync detection. + * @return true if a sync detection is allowed. + */ + protected boolean isSyncDetectionAllowed(int symbolsSinceLastSync) + { + return true; + } + + /** + * Number of consecutive sync periods (capped at 3) where a sync was detected within the max message length. + */ + protected int getConsecutiveSyncPeriods() + { + return mConsecutiveSyncPeriods; + } /** * Finalize and prepare for shutdown. @@ -290,16 +328,16 @@ public abstract class C4FMSymbolProcessor bufferPointer++; samplePoint--; -// mDebugSampleCounter++; + mDebugSampleCounter++; if(samplePoint < 1) { -// mDebugSymbolCounter++; -// -// if(mDebugSymbolCounter % mSyncDetector.getDibitLength() == 0) -// { -// visualizeSymbols(bufferPointer, samplePoint, symbolsSinceLastSync); -// } + mDebugSymbolCounter++; + + if(mDebugSymbolCounter % mSyncDetector.getDibitLength() == 0) + { + visualizeSymbols(bufferPointer, samplePoint, symbolsSinceLastSync); + } symbolsSinceLastSync++; @@ -337,21 +375,26 @@ public abstract class C4FMSymbolProcessor float scoreLag = mSyncDetectorLagging.process(softSymbolLag); scorePrimary = mSyncDetector.process(softSymbol); -// if(mDebugLogDetectionScores) -// { -// boolean exceeds = scorePrimary > 45 || scoreLag > 45; -// System.out.println("DETECT - Primary [" + DEBUG_DF.format(scorePrimary) + "] Lag [" + DEBUG_DF.format(scoreLag) + "] at [" + mDebugSymbolCounter + "]" + (exceeds ? " <<<<<<<<<<<<<<<<<<<<<<<<<<" : "")); -// } + if(mDebugLogDetectionScores) + { + boolean exceeds = scorePrimary > 45 || scoreLag > 45; + System.out.println("DETECT - Primary [" + DEBUG_DF.format(scorePrimary) + "] Lag [" + DEBUG_DF.format(scoreLag) + "] at [" + mDebugSymbolCounter + "]" + (exceeds ? " <<<<<<<<<<<<<<<<<<<<<<<<<<" : "")); + } correctionCandidate = INVALID_SYNC_DETECTION; - if(scorePrimary > getSyncThresholdDetected() && scorePrimary > scoreLag) + if(isSyncDetectionAllowed(symbolsSinceLastSync)) { - correctionCandidate = optimize(0, scorePrimary, (bufferPointer + samplePoint)); - } - else if(scoreLag > getSyncThresholdDetected() && symbolsSinceLastSync > 1) - { - correctionCandidate = optimize(-mLaggingSyncOffset, scoreLag, (bufferPointer + samplePoint)); + if(scorePrimary > getSyncThresholdDetected() && scorePrimary > scoreLag) + { + mActiveSyncDetector = mSyncDetector; + correctionCandidate = optimize(0, scorePrimary, (bufferPointer + samplePoint)); + } + else if(scoreLag > getSyncThresholdDetected() && symbolsSinceLastSync > 1) + { + mActiveSyncDetector = mSyncDetectorLagging; + correctionCandidate = optimize(-mLaggingSyncOffset, scoreLag, (bufferPointer + samplePoint)); + } } //Equalizer processes current soft symbol and returns a delayed soft symbol that is equalized @@ -361,10 +404,10 @@ public abstract class C4FMSymbolProcessor Dibit symbol = Dibit.fromSample(softSymbol); mFeedbackDecoder.broadcast(softSymbol); -// if(mDebugVisualizeSync) -// { -// getDebugSyncResultsViewer().symbol(softSymbol); -// } + if(mDebugVisualizeSync) + { + getDebugSyncResultsViewer().symbol(softSymbol); + } //When the message framer emits at least one valid message (ie CRC check passes) and we have at least // 2 consecutive sync detections, send the current sample equalizer's balance value as a pseudo-PLL @@ -388,12 +431,13 @@ public abstract class C4FMSymbolProcessor { mConsecutiveSyncPeriods = Math.min(3, mConsecutiveSyncPeriods + 1); -// if(mDebugLogDetectionScores) -// { -// System.out.println("DETECT - Primary [" + DEBUG_DF.format(scorePrimary) + "] Lag [" + DEBUG_DF.format(scoreLag) + "] Optimized [" + correctionCandidate.mOptimizationScore + "] at samples [" + mDebugSampleCounter + "] symbols [" + mDebugSymbolCounter + "]"); -// } + if(mDebugLogDetectionScores) + { + System.out.println("DETECT - Primary [" + DEBUG_DF.format(scorePrimary) + "] Lag [" + DEBUG_DF.format(scoreLag) + "] Optimized [" + correctionCandidate.mOptimizationScore + "] at samples [" + mDebugSampleCounter + "] symbols [" + mDebugSymbolCounter + "]"); + } mSampleEqualizer.apply(correctionCandidate); + syncDetected(mActiveSyncDetector); if(mSynchronized) { @@ -424,7 +468,7 @@ public abstract class C4FMSymbolProcessor // need to prime that buffer with aligned samples prior to sync detection training. if(!mSymbolEqualizer.isEnabled()) { - int symbols = mSyncDetector.getPatternSymbols().length + mSymbolEqualizer.getLength() - mSymbolEqualizer.getDelaySymbolCount(); + int symbols = mSyncDetector.getDibitLength() + mSymbolEqualizer.getLength() - mSymbolEqualizer.getDelaySymbolCount(); double offset = bufferPointer + samplePoint - ((symbols - 1) * samplesPerSymbol) - mLaggingSyncOffset; float mid, sym; @@ -440,9 +484,9 @@ public abstract class C4FMSymbolProcessor } } - mSymbolEqualizer.syncDetected(); + mSymbolEqualizer.syncDetected(mActiveSyncDetector.getPatternSymbols()); mSymbolEqualizer.enable(); -// visualizeSyncDetect(scorePrimary, true, "DETECT - POST", bufferPointer, samplePoint); + visualizeSyncDetect(scorePrimary, true, "DETECT - POST", bufferPointer, samplePoint); } else if(mDelayedSyncNotificationPending) { @@ -485,26 +529,27 @@ public abstract class C4FMSymbolProcessor */ private Correction validateSyncWaveform(Correction candidate, int bufferPointer, double samplePoint) { + ReferenceSyncWaveform referenceSyncWaveform = mActiveSyncDetector.getReferenceWaveform(mSamplesPerSymbol); float gain = mSampleEqualizer.mGain + candidate.getGainAdjustment(); float balance = mSampleEqualizer.mBalance + candidate.getBalanceAdjustment(); - double offset = bufferPointer + samplePoint + candidate.getTimingCorrection() - mReferenceSyncWaveform.samples().length; + double offset = bufferPointer + samplePoint + candidate.getTimingCorrection() - referenceSyncWaveform.samples().length; int integral = (int)Math.ceil(offset); double mu = integral - offset; double accumulator = 0.0; int index; //Evaluate the reference waveform in reverse order since the final symbol is aligned to the final sample - for(int x = mReferenceSyncWaveform.samples().length - 1; x >= 0; x--) + for(int x = referenceSyncWaveform.samples().length - 1; x >= 0; x--) { index = integral + x; float sample = LinearInterpolator.calculate(mBuffer[index], mBuffer[index + 1], mu); sample *= gain; sample += balance; - sample = Math.clamp(sample, -mReferenceSyncWaveform.maxSample(), mReferenceSyncWaveform.maxSample()); - accumulator += mReferenceSyncWaveform.samples()[x] * sample; + sample = Math.clamp(sample, -referenceSyncWaveform.maxSample(), referenceSyncWaveform.maxSample()); + accumulator += referenceSyncWaveform.samples()[x] * sample; } - double threshold = mReferenceSyncWaveform.idealCorrelationScore() * 0.85; //85% of the ideal correlation score + double threshold = referenceSyncWaveform.idealCorrelationScore() * 0.85; //85% of the ideal correlation score //Return the correction as valid if the correlation score exceeds the threshold, otherwise return the static // invalid sync detection to nullify the correction. @@ -548,156 +593,157 @@ public abstract class C4FMSymbolProcessor mSyncWorkspaceLength = (int)Math.ceil((mSyncDetector.getDibitLength() + 1) * samplesPerSymbol); mBufferReloadLength = (int)Math.ceil(samplesPerSymbol); mSampleEqualizer.configure(samplesPerSymbol); - mReferenceSyncWaveform = mSyncDetector.getReferenceWaveform(samplesPerSymbol); + //Pre-generate the reference sync waveform for the primary sync pattern + mSyncDetector.getReferenceWaveform(samplesPerSymbol); // System.out.println("Samples Per Symbol: " + samplesPerSymbol); } -// /** -// * Debug viewer UI for visualizing sync detections in the sample stream/waveform. -// */ -// private ISyncResultsListener getDebugSyncResultsViewer() -// { -// if(mDebugSyncResultsViewer == null) -// { -// mDebugSyncResultsViewer = new FMSyncResultsViewer(); -// } -// -// return mDebugSyncResultsViewer; -// } -// -// /** -// * Debug method to visualize the contents of the sync detection for the samples and symbols and symbol timing. -// * @param score from the sync detector -// * @param primary sync detector (true) or secondary (false) -// */ -// public void visualizeSyncDetect(float score, boolean primary, String tag, int bufferPointer, double samplePoint) -// { -// if(!mDebugVisualizeSync || mDebugSymbolCounter < mDebugSymbolStart) -// { -// return; -// } -// -// int integral = (int)Math.floor(samplePoint); -// samplePoint -= integral; -// bufferPointer += integral; -// -// int offset = 3; -// int length = (int)Math.ceil(mSamplesPerSymbol * (mSyncDetector.getDibitLength() - 1)) + (2 * offset); -// int end = bufferPointer + offset; -// int start = end - length; -// float[] symbols = new float[mSyncDetector.getDibitLength()]; -// float[] samples = Arrays.copyOfRange(mBuffer, start, end); -// for(int i = 0; i < samples.length; i++) -// { -// samples[i] = mSampleEqualizer.equalize(samples[i]); -// } -// -// double symbolPointer = bufferPointer + samplePoint - (mSamplesPerSymbol * (mSyncDetector.getDibitLength() - 1)); -// int symbolIntegral = (int)Math.floor(symbolPointer); -// double mu = symbolPointer - symbolIntegral; -// -// for(int x = 0; x < mSyncDetector.getDibitLength(); x++) -// { -// symbols[x] = mSampleEqualizer.getEqualizedSymbol(mBuffer[symbolIntegral], mBuffer[symbolIntegral + 1], mu); -// symbolPointer += mSamplesPerSymbol; -// symbolIntegral = (int)Math.floor(symbolPointer); -// mu = symbolPointer - symbolIntegral; -// } -// -// float[] syncIntervals = new float[mSyncDetector.getDibitLength()]; -// int adjust = bufferPointer - length + offset; -// double pointer = bufferPointer + samplePoint - adjust; -// -// for(int x = (mSyncDetector.getDibitLength() - 1); x >= 0; x--) -// { -// syncIntervals[x] = (float)pointer; -// pointer -= mSamplesPerSymbol; -// } -// -// CountDownLatch countDownLatch = new CountDownLatch(1); -// getDebugSyncResultsViewer().receive(symbols, mSyncDetector.getPatternSymbols(), samples, syncIntervals, mSampleEqualizer.mBalance, mSampleEqualizer.mGain, -// tag + " Score: " + score + (primary ? " PRIMARY " : " SECONDARY ") + (mSynchronized ? "FINE " : "COARSE ") + -// " EQ-BO:" + DEBUG_DF.format(mSampleEqualizer.mBalanceOptimized) + -// " EQ-B:" + DEBUG_DF.format(mSampleEqualizer.mBalance) + -// " EQ-G:" + DEBUG_DF.format(mSampleEqualizer.mGain) + " " + -// " SAM:" + mDebugSampleCounter + -// " SYM:" + mDebugSymbolCounter + -// " DUR:" + DEBUG_DF.format(mDebugSymbolCounter / 2400.0), -// countDownLatch); -// -// try -// { -// countDownLatch.await(); -// } -// catch(InterruptedException e) -// { -// throw new RuntimeException(e); -// } -// } -// -// /** -// * Debug method to visualize the symbol decisions -// */ -// public void visualizeSymbols(int bufferPointer, double samplePoint, int symbolsSinceLastSync) -// { -// if(!mDebugVisualizeSymbols || mDebugSymbolCounter < mDebugSymbolStart) -// { -// return; -// } -// -// int syncLength = mSyncDetector.getDibitLength(); -// -// int offset = 3; -// int length = (int)Math.ceil(mSamplesPerSymbol * (syncLength - 1)) + (2 * offset); -// int end = bufferPointer + offset; -// int start = end - length; -// float[] symbols = new float[syncLength]; -// float[] decisions = new float[syncLength]; -// float[] samples = Arrays.copyOfRange(mBuffer, start, end); -// for(int i = 0; i < samples.length; i++) -// { -// samples[i] = mSampleEqualizer.equalize(samples[i]); -// } -// -// double symbolPointer = bufferPointer + samplePoint - (mSamplesPerSymbol * (syncLength - 1)); -// int symbolIntegral = (int)Math.floor(symbolPointer); -// double mu = symbolPointer - symbolIntegral; -// -// for(int x = 0; x < syncLength; x++) -// { -// symbols[x] = mSampleEqualizer.getEqualizedSymbol(mBuffer[symbolIntegral], mBuffer[symbolIntegral + 1], mu); -// decisions[x] = Dibit.fromSample(symbols[x]).getIdealPhase(); -// symbolPointer += mSamplesPerSymbol; -// symbolIntegral = (int)Math.floor(symbolPointer); -// mu = symbolPointer - symbolIntegral; -// } -// -// float[] syncIntervals = new float[syncLength]; -// int adjust = bufferPointer - length + offset; -// double pointer = bufferPointer + samplePoint - adjust; -// -// for(int x = syncLength - 1; x >= 0; x--) -// { -// syncIntervals[x] = (float)pointer; -// pointer -= mSamplesPerSymbol; -// } -// -// CountDownLatch countDownLatch = new CountDownLatch(1); -// getDebugSyncResultsViewer().receive(symbols, decisions, samples, syncIntervals, mSampleEqualizer.mBalance, mSampleEqualizer.mGain, -// "Balance:" + mSampleEqualizer.mBalance + " Gain:" + mSampleEqualizer.mGain + -// " SAM:" + mDebugSampleCounter + " SYM:" + mDebugSymbolCounter + -// " DUR:" + DEBUG_DF.format(mDebugSymbolCounter / 2400.0) + -// " ELAPSED:" + symbolsSinceLastSync, countDownLatch); -// -// try -// { -// countDownLatch.await(); -// } -// catch(InterruptedException e) -// { -// throw new RuntimeException(e); -// } -// } + /** + * Debug viewer UI for visualizing sync detections in the sample stream/waveform. + */ + private ISyncResultsListener getDebugSyncResultsViewer() + { + if(mDebugSyncResultsViewer == null) + { + mDebugSyncResultsViewer = new FMSyncResultsViewer(); + } + + return mDebugSyncResultsViewer; + } + + /** + * Debug method to visualize the contents of the sync detection for the samples and symbols and symbol timing. + * @param score from the sync detector + * @param primary sync detector (true) or secondary (false) + */ + public void visualizeSyncDetect(float score, boolean primary, String tag, int bufferPointer, double samplePoint) + { + if(!mDebugVisualizeSync || mDebugSymbolCounter < mDebugSymbolStart) + { + return; + } + + int integral = (int)Math.floor(samplePoint); + samplePoint -= integral; + bufferPointer += integral; + + int offset = 3; + int length = (int)Math.ceil(mSamplesPerSymbol * (mSyncDetector.getDibitLength() - 1)) + (2 * offset); + int end = bufferPointer + offset; + int start = end - length; + float[] symbols = new float[mSyncDetector.getDibitLength()]; + float[] samples = Arrays.copyOfRange(mBuffer, start, end); + for(int i = 0; i < samples.length; i++) + { + samples[i] = mSampleEqualizer.equalize(samples[i]); + } + + double symbolPointer = bufferPointer + samplePoint - (mSamplesPerSymbol * (mSyncDetector.getDibitLength() - 1)); + int symbolIntegral = (int)Math.floor(symbolPointer); + double mu = symbolPointer - symbolIntegral; + + for(int x = 0; x < mSyncDetector.getDibitLength(); x++) + { + symbols[x] = mSampleEqualizer.getEqualizedSymbol(mBuffer[symbolIntegral], mBuffer[symbolIntegral + 1], mu); + symbolPointer += mSamplesPerSymbol; + symbolIntegral = (int)Math.floor(symbolPointer); + mu = symbolPointer - symbolIntegral; + } + + float[] syncIntervals = new float[mSyncDetector.getDibitLength()]; + int adjust = bufferPointer - length + offset; + double pointer = bufferPointer + samplePoint - adjust; + + for(int x = (mSyncDetector.getDibitLength() - 1); x >= 0; x--) + { + syncIntervals[x] = (float)pointer; + pointer -= mSamplesPerSymbol; + } + + CountDownLatch countDownLatch = new CountDownLatch(1); + getDebugSyncResultsViewer().receive(symbols, mSyncDetector.getPatternSymbols(), samples, syncIntervals, mSampleEqualizer.mBalance, mSampleEqualizer.mGain, + tag + " Score: " + score + (primary ? " PRIMARY " : " SECONDARY ") + (mSynchronized ? "FINE " : "COARSE ") + + " EQ-BO:" + DEBUG_DF.format(mSampleEqualizer.mBalanceOptimized) + + " EQ-B:" + DEBUG_DF.format(mSampleEqualizer.mBalance) + + " EQ-G:" + DEBUG_DF.format(mSampleEqualizer.mGain) + " " + + " SAM:" + mDebugSampleCounter + + " SYM:" + mDebugSymbolCounter + + " DUR:" + DEBUG_DF.format(mDebugSymbolCounter / 2400.0), + countDownLatch); + + try + { + countDownLatch.await(); + } + catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + + /** + * Debug method to visualize the symbol decisions + */ + public void visualizeSymbols(int bufferPointer, double samplePoint, int symbolsSinceLastSync) + { + if(!mDebugVisualizeSymbols || mDebugSymbolCounter < mDebugSymbolStart) + { + return; + } + + int syncLength = mSyncDetector.getDibitLength(); + + int offset = 3; + int length = (int)Math.ceil(mSamplesPerSymbol * (syncLength - 1)) + (2 * offset); + int end = bufferPointer + offset; + int start = end - length; + float[] symbols = new float[syncLength]; + float[] decisions = new float[syncLength]; + float[] samples = Arrays.copyOfRange(mBuffer, start, end); + for(int i = 0; i < samples.length; i++) + { + samples[i] = mSampleEqualizer.equalize(samples[i]); + } + + double symbolPointer = bufferPointer + samplePoint - (mSamplesPerSymbol * (syncLength - 1)); + int symbolIntegral = (int)Math.floor(symbolPointer); + double mu = symbolPointer - symbolIntegral; + + for(int x = 0; x < syncLength; x++) + { + symbols[x] = mSampleEqualizer.getEqualizedSymbol(mBuffer[symbolIntegral], mBuffer[symbolIntegral + 1], mu); + decisions[x] = Dibit.fromSample(symbols[x]).getIdealPhase(); + symbolPointer += mSamplesPerSymbol; + symbolIntegral = (int)Math.floor(symbolPointer); + mu = symbolPointer - symbolIntegral; + } + + float[] syncIntervals = new float[syncLength]; + int adjust = bufferPointer - length + offset; + double pointer = bufferPointer + samplePoint - adjust; + + for(int x = syncLength - 1; x >= 0; x--) + { + syncIntervals[x] = (float)pointer; + pointer -= mSamplesPerSymbol; + } + + CountDownLatch countDownLatch = new CountDownLatch(1); + getDebugSyncResultsViewer().receive(symbols, decisions, samples, syncIntervals, mSampleEqualizer.mBalance, mSampleEqualizer.mGain, + "Balance:" + mSampleEqualizer.mBalance + " Gain:" + mSampleEqualizer.mGain + + " SAM:" + mDebugSampleCounter + " SYM:" + mDebugSymbolCounter + + " DUR:" + DEBUG_DF.format(mDebugSymbolCounter / 2400.0) + + " ELAPSED:" + symbolsSinceLastSync, countDownLatch); + + try + { + countDownLatch.await(); + } + catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } /** * Calculates the standard deviation of the samples at the specified offset that aligns with a sync detection. @@ -965,7 +1011,10 @@ public abstract class C4FMSymbolProcessor /** * Applies decision directed correction to the constellation balance when dynamic balance tracking is enabled - * and the processor is synchronized following a valid sync detection. + * and the processor is synchronized following a valid sync detection. In periods of high noise or when the + * signal has stopped but we haven't yet detected it, we apply a correction that gradually restores the balance + * back towards the optimized balance decided at the last valid sync detection. This ensures that when the + * signal returns, the balance can be reestablished quickly. * @param symbol sliced at the correct timing */ public float correct(float symbol) @@ -1153,8 +1202,9 @@ public abstract class C4FMSymbolProcessor { int maxPointer = mBuffer.length - 1; float softSymbol; + float[] patternSymbols = mActiveSyncDetector.getPatternSymbols(); - double pointer = offset - (samplesPerSymbol * (mSyncDetector.getDibitLength() - 1)); + double pointer = offset - (samplesPerSymbol * (patternSymbols.length - 1)); int bufferPointer = (int)Math.floor(pointer); double fractional = pointer - bufferPointer; @@ -1162,7 +1212,7 @@ public abstract class C4FMSymbolProcessor float sample1, sample2; float idealSymbol; - for(int x = 0; x < mSyncDetector.getDibitLength(); x++) + for(int x = 0; x < patternSymbols.length; x++) { if(bufferPointer < maxPointer) { @@ -1175,7 +1225,7 @@ public abstract class C4FMSymbolProcessor softSymbol = 0.0f; } - idealSymbol = mSyncDetector.getPatternSymbols()[x]; + idealSymbol = patternSymbols[x]; //Constrain the contribution of the soft symbol to +/- the ideal symbol value. if(idealSymbol > 0) @@ -1246,22 +1296,24 @@ public abstract class C4FMSymbolProcessor public Correction getCorrection(double additionalOffset, double timingCorrection, float detectionScore, float optimizationScore, double offset, float measuredBalance) { - double resampleStart = offset + additionalOffset + timingCorrection - (mSamplesPerSymbol * (mSyncDetector.getDibitLength() - 1)); + float[] patternSymbols = mActiveSyncDetector.getPatternSymbols(); + Dibit[] patternDibits = mActiveSyncDetector.getPatternDibits(); + double resampleStart = offset + additionalOffset + timingCorrection - (mSamplesPerSymbol * (patternSymbols.length - 1)); int resampleStartIntegral = (int)Math.floor(resampleStart); float symbol, gainAccumulator = 0, idealMagnitudeAccumulator = 0, resampledSoftSymbol; Dibit resampledDibit; int bitErrorCount = 0; - for(int x = 0; x < mSyncDetector.getDibitLength(); x++) + for(int x = 0; x < patternSymbols.length; x++) { if(resampleStartIntegral >= 0) { - symbol = mSyncDetector.getPatternSymbols()[x]; + symbol = patternSymbols[x]; resampledSoftSymbol = LinearInterpolator.calculate(mBuffer[resampleStartIntegral], mBuffer[resampleStartIntegral + 1], resampleStart - resampleStartIntegral); resampledSoftSymbol = resampledSoftSymbol * mSampleEqualizer.mGain + measuredBalance; resampledDibit = Dibit.fromSample(resampledSoftSymbol); - bitErrorCount += mSyncDetector.getPatternDibits()[x].getBitErrorFrom(resampledDibit); + bitErrorCount += patternDibits[x].getBitErrorFrom(resampledDibit); gainAccumulator += Math.abs(symbol) - Math.abs(resampledSoftSymbol); idealMagnitudeAccumulator += Math.abs(symbol); } diff --git a/src/main/java/io/github/dsheirer/dsp/c4fm/ISoftC4FMSyncDetector.java b/src/main/java/io/github/dsheirer/dsp/c4fm/ISoftC4FMSyncDetector.java index 339e6f2a..9b783c3b 100644 --- a/src/main/java/io/github/dsheirer/dsp/c4fm/ISoftC4FMSyncDetector.java +++ b/src/main/java/io/github/dsheirer/dsp/c4fm/ISoftC4FMSyncDetector.java @@ -15,13 +15,14 @@ public interface ISoftC4FMSyncDetector float process(float softSymbol); /** - * Soft symbols that represent the sync pattern + * Soft symbols that represent the sync pattern. For detectors that monitor multiple sync patterns (ie DMR), this + * is the sync pattern that produced the score from the most recent call to process(). * @return soft symbols array */ float[] getPatternSymbols(); /** - * Array of dibits for the sync pattern + * Array of dibits for the sync pattern (or most recently detected sync pattern for multi-pattern detectors). * @return sync pattern dibit array */ Dibit[] getPatternDibits(); @@ -32,7 +33,8 @@ public interface ISoftC4FMSyncDetector int getDibitLength(); /** - * Provides a reference waveform that represents the sync pattern for sync detection validation + * Provides a reference waveform that represents the sync pattern (or most recently detected sync pattern for + * multi-pattern detectors) for sync detection validation * @param samplesPerSymbol for each symbol * @return reference waveform */ diff --git a/src/main/java/io/github/dsheirer/edac/soft/SoftBlockCode.java b/src/main/java/io/github/dsheirer/edac/soft/SoftBlockCode.java new file mode 100644 index 00000000..0774e5fe --- /dev/null +++ b/src/main/java/io/github/dsheirer/edac/soft/SoftBlockCode.java @@ -0,0 +1,288 @@ +/* + * ***************************************************************************** + * 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.edac.soft; + +import java.util.Arrays; +import java.util.function.IntPredicate; + +/** + * Soft decision decoder for short binary block codes, defined by the full set of valid codewords. + * + * Codewords are represented as integers where bit i (LSB = 0) holds codeword bit position i. Soft bit values are + * log-likelihood ratios where a positive value favors a 1 bit and the magnitude indicates the reliability. + * + * Provides Chase-Pyndiah soft-in/soft-out decoding: tests the codewords nearest to the hard decision with the least + * reliable bits flipped and produces extrinsic soft information for iterative (ie product code) decoding. + */ +public class SoftBlockCode +{ + private static final int MAX_LENGTH = 17; + private final int mLength; + private final int[] mCodewords; + private final int[] mDecodeTable; + + /** + * Constructs an instance. + * @param length of the code in bits (maximum 17). + * @param codewords set of all valid codewords. + */ + public SoftBlockCode(int length, int[] codewords) + { + if(length < 1 || length > MAX_LENGTH) + { + throw new IllegalArgumentException("Code length must be in range 1 - " + MAX_LENGTH); + } + + mLength = length; + mCodewords = codewords.clone(); + + //Single bit error correction table: maps each word within distance 1 of a codeword to that codeword, or -1. + mDecodeTable = new int[1 << length]; + Arrays.fill(mDecodeTable, -1); + + for(int codeword: codewords) + { + mDecodeTable[codeword] = codeword; + + for(int i = 0; i < length; i++) + { + mDecodeTable[codeword ^ (1 << i)] = codeword; + } + } + } + + /** + * Creates an instance by testing every possible word of the specified length to identify the valid codewords. + * @param length of the code in bits (maximum 17). + * @param isCodeword test that indicates if the word (bit i = codeword position i) is a valid codeword. + * @return soft block code. + */ + public static SoftBlockCode fromCodewordTest(int length, IntPredicate isCodeword) + { + int count = 0; + int[] candidates = new int[1 << length]; + + for(int word = 0; word < (1 << length); word++) + { + if(isCodeword.test(word)) + { + candidates[count++] = word; + } + } + + return new SoftBlockCode(length, Arrays.copyOf(candidates, count)); + } + + /** + * Code length in bits + */ + public int getLength() + { + return mLength; + } + + /** + * Valid codewords + */ + public int[] getCodewords() + { + return mCodewords; + } + + /** + * Indicates if the word is a valid codeword. + */ + public boolean isCodeword(int word) + { + return mDecodeTable[word] == word; + } + + /** + * Hard decision of the soft bits. + * @param softBits array + * @param indices of the code bits in the soft bits array + * @return hard decision word. + */ + public static int getHardDecision(float[] softBits, int[] indices) + { + int word = 0; + + for(int i = 0; i < indices.length; i++) + { + if(softBits[indices[i]] > 0) + { + word |= (1 << i); + } + } + + return word; + } + + /** + * Calculates the reliability metric for the candidate codeword: the sum of the soft bit magnitudes for each bit + * where the candidate differs from the hard decision. Lower values are more likely. + */ + private static float getMetric(int candidate, int hard, float[] magnitudes) + { + float metric = 0.0f; + int difference = candidate ^ hard; + + while(difference != 0) + { + int bit = Integer.numberOfTrailingZeros(difference); + metric += magnitudes[bit]; + difference &= difference - 1; + } + + return metric; + } + + /** + * Chase-Pyndiah soft-in/soft-out decode. Generates test patterns by flipping each combination of the least + * reliable bits in the hard decision, decodes each with the single bit error correction table and selects the + * most likely candidate codeword as the decision. For each bit, the extrinsic value is the metric difference + * between the decision and the best competing candidate that differs at that bit, less the soft input, or beta + * (signed by the decision) when there is no competing candidate. When no test pattern decodes to a codeword, the + * extrinsic values are zero. + * + * @param input soft bits array + * @param indices of the code bits in the input and output arrays + * @param output array to receive the extrinsic soft values at the code bit indices + * @param leastReliableCount number of least reliable bits to use for test patterns + * @param beta reliability to assign to a bit when there is no competing candidate codeword. + * @return decision codeword, or -1 if no test pattern decoded to a codeword. + */ + public int decodeChase(float[] input, int[] indices, float[] output, int leastReliableCount, float beta) + { + float[] magnitudes = new float[mLength]; + + for(int i = 0; i < mLength; i++) + { + magnitudes[i] = Math.abs(input[indices[i]]); + } + + int hard = getHardDecision(input, indices); + + //Identify the least reliable bit positions using selection sort over the small code length + int p = Math.min(leastReliableCount, mLength); + int[] leastReliable = new int[p]; + int used = 0; + + for(int x = 0; x < p; x++) + { + int minIndex = -1; + + for(int i = 0; i < mLength; i++) + { + if((used & (1 << i)) == 0 && (minIndex < 0 || magnitudes[i] < magnitudes[minIndex])) + { + minIndex = i; + } + } + + leastReliable[x] = minIndex; + used |= (1 << minIndex); + } + + //Decode each test pattern into a unique candidate codeword + int[] candidates = new int[1 << p]; + float[] metrics = new float[1 << p]; + int candidateCount = 0; + int decision = -1; + float decisionMetric = Float.MAX_VALUE; + + for(int pattern = 0; pattern < (1 << p); pattern++) + { + int test = hard; + + for(int b = 0; b < p; b++) + { + if((pattern & (1 << b)) != 0) + { + test ^= (1 << leastReliable[b]); + } + } + + int candidate = mDecodeTable[test]; + + if(candidate < 0) + { + continue; + } + + boolean duplicate = false; + + for(int c = 0; c < candidateCount; c++) + { + if(candidates[c] == candidate) + { + duplicate = true; + break; + } + } + + if(!duplicate) + { + float metric = getMetric(candidate, hard, magnitudes); + candidates[candidateCount] = candidate; + metrics[candidateCount] = metric; + candidateCount++; + + if(metric < decisionMetric) + { + decisionMetric = metric; + decision = candidate; + } + } + } + + for(int i = 0; i < mLength; i++) + { + if(decision < 0) + { + output[indices[i]] = 0.0f; + continue; + } + + int mask = 1 << i; + float sign = (decision & mask) != 0 ? 1.0f : -1.0f; + float competitorMetric = Float.MAX_VALUE; + + for(int c = 0; c < candidateCount; c++) + { + if(((candidates[c] ^ decision) & mask) != 0 && metrics[c] < competitorMetric) + { + competitorMetric = metrics[c]; + } + } + + if(competitorMetric < Float.MAX_VALUE) + { + output[indices[i]] = sign * (competitorMetric - decisionMetric) - input[indices[i]]; + } + else + { + output[indices[i]] = sign * beta; + } + } + + return decision; + } +} diff --git a/src/main/java/io/github/dsheirer/edac/soft/SoftProductCodeDecoder.java b/src/main/java/io/github/dsheirer/edac/soft/SoftProductCodeDecoder.java new file mode 100644 index 00000000..94303bfd --- /dev/null +++ b/src/main/java/io/github/dsheirer/edac/soft/SoftProductCodeDecoder.java @@ -0,0 +1,278 @@ +/* + * ***************************************************************************** + * 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.edac.soft; + +import io.github.dsheirer.bits.CorrectedBinaryMessage; + +/** + * Iterative soft decision decoder for product codes where each row is a codeword of a short block code and each + * column has even parity (ie the final row is the column parity row). Bits are ordered row by row. + * + * Alternately decodes the 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 (sum of the normalized channel soft bit magnitudes for the corrected bits) exceeds the + * maximum, since a low correction cost indicates a reliable decision. + */ +public class SoftProductCodeDecoder +{ + private static final int CHASE_LEAST_RELIABLE_BITS = 4; + //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 row bits without a competing + //candidate codeword. + 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 final SoftBlockCode mRowCode; + private final int mRowCount; + private final int mColumnCount; + private final int mLength; + private final int[][] mRowIndexes; + private final float mMaxCorrectionCost; + + /** + * Constructs an instance + * @param rowCode for each row, where the code length is the column count + * @param rowCount number of rows, including the column parity row + * @param maxCorrectionCost maximum correction cost for a decision, or Float.MAX_VALUE for no limit + */ + public SoftProductCodeDecoder(SoftBlockCode rowCode, int rowCount, float maxCorrectionCost) + { + mRowCode = rowCode; + mRowCount = rowCount; + mColumnCount = rowCode.getLength(); + mLength = mRowCount * mColumnCount; + mMaxCorrectionCost = maxCorrectionCost; + mRowIndexes = new int[mRowCount][mColumnCount]; + + for(int row = 0; row < mRowCount; row++) + { + for(int column = 0; column < mColumnCount; column++) + { + mRowIndexes[row][column] = row * mColumnCount + column; + } + } + } + + /** + * Decodes the (de-interleaved) soft bits. + * @param channel soft bits ordered row by row 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 decode(float[] channel) + { + if(channel == null || channel.length != mLength) + { + return null; + } + + //Normalize the channel soft bits to a mean magnitude of 1 for the alpha and beta settings + float sum = 0.0f; + + for(float softBit: channel) + { + sum += Math.abs(softBit); + } + + if(sum <= 0.0f) + { + return null; + } + + float scale = mLength / sum; + float[] received = new float[mLength]; + + for(int i = 0; i < mLength; i++) + { + received[i] = channel[i] * scale; + } + + float[] extrinsic = new float[mLength]; + float[] input = new float[mLength]; + float[] output = new float[mLength]; + + for(int halfIteration = 0; halfIteration < SOFT_ALPHA.length; halfIteration++) + { + float alpha = SOFT_ALPHA[halfIteration]; + + for(int i = 0; i < mLength; i++) + { + input[i] = received[i] + alpha * extrinsic[i]; + } + + CorrectedBinaryMessage decoded = new CorrectedBinaryMessage(mLength); + + if(halfIteration % 2 == 0) + { + boolean allDecoded = true; + + for(int row = 0; row < mRowCount; row++) + { + int word = mRowCode.decodeChase(input, mRowIndexes[row], output, CHASE_LEAST_RELIABLE_BITS, + SOFT_BETA[halfIteration]); + allDecoded &= word >= 0; + + for(int column = 0; column < mColumnCount; column++) + { + if((word & (1 << column)) != 0) + { + decoded.set(mRowIndexes[row][column]); + } + } + } + + if(!allDecoded) + { + decoded = null; + } + } + else + { + decodeColumns(input, output); + + for(int i = 0; i < mLength; i++) + { + if(input[i] + output[i] > 0) + { + decoded.set(i); + } + } + } + + //Check for a valid product codeword from the decisions of this half iteration + if(decoded != null && isValid(decoded)) + { + int corrected = 0; + float cost = 0.0f; + + for(int i = 0; i < mLength; i++) + { + if(decoded.get(i) != (received[i] > 0)) + { + corrected++; + cost += Math.abs(received[i]); + } + } + + if(cost > mMaxCorrectionCost) + { + return null; + } + + 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(float value: output) + { + extrinsicSum += Math.abs(value); + } + + float extrinsicScale = extrinsicSum > 0.0f ? mLength / extrinsicSum : 0.0f; + + for(int i = 0; i < mLength; i++) + { + extrinsic[i] = output[i] * extrinsicScale; + } + } + + return null; + } + + /** + * Indicates if each row is a row codeword and each column has even parity. + */ + private boolean isValid(CorrectedBinaryMessage message) + { + int parity = 0; + + for(int row = 0; row < mRowCount; row++) + { + int word = 0; + + for(int column = 0; column < mColumnCount; column++) + { + if(message.get(mRowIndexes[row][column])) + { + word |= (1 << column); + } + } + + if(!mRowCode.isCodeword(word)) + { + return false; + } + + parity ^= word; + } + + return parity == 0; + } + + /** + * Min-sum (max-log) soft-in/soft-out decoding of the even parity columns. The extrinsic value for each bit is + * the product of the signs and the minimum magnitude of the other bits in the column. + * @param input soft bits + * @param output to receive the extrinsic values. + */ + private void decodeColumns(float[] input, float[] output) + { + for(int column = 0; column < mColumnCount; column++) + { + float sign = 1.0f; + float minimum1 = Float.MAX_VALUE; + float minimum2 = Float.MAX_VALUE; + int minimumRow = -1; + + for(int row = 0; row < mRowCount; row++) + { + float value = input[mRowIndexes[row][column]]; + + if(value < 0) + { + sign = -sign; + } + + float magnitude = Math.abs(value); + + if(magnitude < minimum1) + { + minimum2 = minimum1; + minimum1 = magnitude; + minimumRow = row; + } + else if(magnitude < minimum2) + { + minimum2 = magnitude; + } + } + + for(int row = 0; row < mRowCount; row++) + { + int index = mRowIndexes[row][column]; + float value = input[index]; + float otherSign = value < 0 ? -sign : sign; + output[index] = otherSign * (row == minimumRow ? minimum2 : minimum1); + } + } + } +} diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoder.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoder.java index 49502e4b..754979c7 100644 --- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoder.java +++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoder.java @@ -26,8 +26,8 @@ import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter; import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification; import io.github.dsheirer.dsp.filter.fir.real.IRealFilter; import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter; -import io.github.dsheirer.dsp.psk.demod.DifferentialDemodulatorFactory; -import io.github.dsheirer.dsp.psk.demod.DifferentialDemodulatorFloat; +import io.github.dsheirer.dsp.fm.FmDemodulatorFactory; +import io.github.dsheirer.dsp.fm.IDemodulator; import io.github.dsheirer.message.IMessage; import io.github.dsheirer.message.SyncLossMessage; import io.github.dsheirer.module.decode.DecoderType; @@ -40,6 +40,7 @@ import io.github.dsheirer.module.decode.dmr.message.data.lc.LCMessage; import io.github.dsheirer.module.decode.dmr.message.data.lc.full.FullLCMessage; import io.github.dsheirer.module.decode.dmr.message.data.lc.shorty.ShortLCMessage; import io.github.dsheirer.module.decode.dmr.message.data.terminator.Terminator; +import io.github.dsheirer.module.decode.nxdn.NXDNFilterDesigner; import io.github.dsheirer.preference.UserPreferences; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.sample.buffer.IByteBufferProvider; @@ -52,25 +53,24 @@ import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.text.DecimalFormat; -import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * DMR DQPSK decoder. + * DMR 4FSK decoder. * - * Processes the sample stream using an optimal (scalar or vector) DQPSK demodulator. The demodulated sample stream - * is processed by the DMRSoftSymbolProcessor at each symbol time interval using a primary and two lagging soft sync - * detectors. The sync detectors use correlation of the demodulated soft symbols with all known sync patterns to - * detect the sync and align the symbol timing and symbol period to the optimal location and symbol period as - * calculated across each detected burst. Once the symbol processor has fingerprinted the channel type, it optimizes - * sync detection for one of three detected modes (BASE, MOBILE or DIRECT) to reduce sync correlation workload. + * Processes the sample stream using an FM demodulator and a root raised cosine receive filter matched to the DMR + * transmit filter. The demodulated sample stream is processed by the DMRSymbolProcessor (C4FM symbol processor) + * at each symbol time interval using a primary and a lagging soft sync detector. The sync detectors use correlation + * of the demodulated soft symbols with all known sync patterns to detect the sync and align the symbol timing and + * constellation gain and balance. Once the symbol processor has fingerprinted the channel type, it optimizes sync + * detection for one of three detected modes (BASE, MOBILE or DIRECT) to reduce sync correlation workload. * - * The DMRSoftSymbolMessageFramer receives sync detections from the soft symbol processor and buffers symbols from the - * start of the burst through the sync so that the symbols can be resampled when the sync is detected, ensuring that - * the burst symbols are accurate using the symbol timing and period values calculated on the sync detection. + * The DMRMessageFramer receives sync detections from the soft symbol processor and buffers symbols from the start of + * the burst through the sync so that the full burst can be framed once the sync is detected. * * For Direct Mode where one timeslot is empty, the symbol message framer uses two message burst buffers that alternate * for each burst so that one buffer tracks the active timeslot and the other buffer tracks the empty timeslot to @@ -85,18 +85,19 @@ public class DMRDecoder extends FeedbackDecoder implements IByteBufferProvider, private static final Logger LOGGER = LoggerFactory.getLogger(DMRDecoder.class); private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.##"); private static final int SYMBOL_RATE = 4800; - private static final float MAXIMUM_CARRIER_OFFSET = 5000.0f; //Threshold for retuning the signal. - private static final Map BASEBAND_FILTERS = new HashMap<>(); - private DifferentialDemodulatorFloat mDemodulator; + //DMR transmit filter is a root raised cosine with 0.2 roll-off, so the receive filter is a matched RRC filter. + private static final double RECEIVE_FILTER_ALPHA = 0.2; + private static final int RECEIVE_FILTER_SYMBOL_SPAN = 12; + private static final Map 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"); + } +}