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 052a0ac2..4bf5c346 100644
--- a/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java
+++ b/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMSymbolProcessor.java
@@ -257,6 +257,20 @@ public abstract class C4FMSymbolProcessor
return true;
}
+ /**
+ * Maximum bit errors allowed in a detected sync pattern while acquiring (ie not synchronized), measured from the
+ * symbol decisions at the optimized symbol timing. Sync detections with more bit errors are rejected. Bit errors
+ * include symbol magnitude (ie +3 vs +1) errors, so this rejects noise where the symbol signs correlate with the
+ * sync pattern but the symbol magnitudes are inconsistent. Once synchronized, the limit isn't applied since the
+ * sync detection timing is known and rejecting a valid sync detection costs the burst. Default implementation
+ * imposes no limit.
+ * @return maximum sync pattern bit error count while acquiring.
+ */
+ public int getSyncAcquisitionMaxBitErrors()
+ {
+ return Integer.MAX_VALUE;
+ }
+
/**
* Number of consecutive sync periods (capped at 3) where a sync was detected within the max message length.
*/
@@ -945,12 +959,14 @@ public abstract class C4FMSymbolProcessor
}
/**
- * Indicates if this correction has a valid NID or the optimization (or detection) score is high quality.
+ * Indicates if the optimization score is high quality and, while acquiring, the sync pattern bit errors don't
+ * exceed the max.
* @return true if valid.
*/
public boolean isValid()
{
- return mOptimizationScore > getSyncThresholdEqualized();
+ return mOptimizationScore > getSyncThresholdEqualized() &&
+ (mSynchronized || mCorrectedBitCount <= getSyncAcquisitionMaxBitErrors());
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java
index 9bee8e44..7ae18b12 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRDecoderState.java
@@ -298,7 +298,10 @@ public class DMRDecoderState extends TimeslotDecoderState
{
if(message instanceof VoiceMessage voice)
{
- processVoice(voice);
+ if(isValid(voice))
+ {
+ processVoice(voice);
+ }
}
else if(message instanceof DataMessage data)
{
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
index d65fa660..49c91c51 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSymbolProcessor.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/DMRSymbolProcessor.java
@@ -66,6 +66,12 @@ public class DMRSymbolProcessor extends C4FMSymbolProcessor
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;
+ //Maximum bit errors (of 48) in a detected sync pattern while acquiring. Testing: all false sync detections in
+ //600 seconds of noise occurred while acquiring, with 7+ (mostly 12+) bit errors, and a max of 8 eliminated them
+ //(245 bursts to 0) at a cost of < 0.6% valid data bursts on clean recordings and 0.3 - 5.5% on multipath
+ //recordings with injected noise, where valid link control improved. A max of 10 leaks noise false syncs and 6
+ //costs more multipath data bursts. Applying the max once synchronized cost 3 - 20% of valid bursts.
+ private static final int SYNC_ACQUISITION_MAX_BIT_ERRORS = 8;
private final DMRSyncModeMonitor mSyncModeMonitor = new DMRSyncModeMonitor();
private final DMRMessageFramer mMessageFramer;
@@ -142,6 +148,12 @@ public class DMRSymbolProcessor extends C4FMSymbolProcessor
return Math.min(offset, BURST_DIBIT_LENGTH - offset) <= SYNC_WINDOW_SYMBOLS;
}
+ @Override
+ public int getSyncAcquisitionMaxBitErrors()
+ {
+ return SYNC_ACQUISITION_MAX_BIT_ERRORS;
+ }
+
@Override
public boolean isDynamicBalanceTrackingRequired()
{
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/audio/DMRAudioModule.java b/src/main/java/io/github/dsheirer/module/decode/dmr/audio/DMRAudioModule.java
index eb503656..e54744cf 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/audio/DMRAudioModule.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/audio/DMRAudioModule.java
@@ -105,6 +105,13 @@ public class DMRAudioModule extends AmbeAudioModule implements IdentifierUpdateP
{
if(hasAudioCodec() && message.getTimeslot() == getTimeslot())
{
+ //Ignore voice messages where none of the AMBE voice frames pass error detection, so that invalid voice
+ //messages (ie false sync detections) don't produce audio or establish the encryption state.
+ if(message instanceof VoiceMessage voiceMessage && !voiceMessage.isValid())
+ {
+ return;
+ }
+
//Attempt to set the audio encryption state from certain types of messages
if(!mEncryptedCallStateEstablished)
{
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidator.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidator.java
new file mode 100644
index 00000000..d652f520
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidator.java
@@ -0,0 +1,207 @@
+/*
+ * *****************************************************************************
+ * 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.voice;
+
+import io.github.dsheirer.bits.BinaryMessage;
+
+/**
+ * Error detection for the three AMBE+2 (3600 x 2450) voice frames carried in a DMR voice burst.
+ *
+ * Each 72-bit voice frame carries an extended Golay(24,12) protected C0 vector and a Golay(23,12) protected C1 vector
+ * that is scrambled with a pseudo-random sequence seeded by the 12 data bits of C0. The C2 and C3 vectors are not
+ * protected. Golay(23,12) is a perfect code where every 23-bit word decodes to a codeword within 3 bit errors, so a
+ * frame is only considered valid when the combined C0 and C1 error count is low, which random (ie noise) bits rarely
+ * achieve.
+ *
+ * Vector bit indices and the C1 scrambling sequence are from the JMBE AMBEFrame decoder. Burst bit indices of each
+ * voice frame match VoiceMessage.getAMBEFrames(): frames 1 and 3 are contiguous and frame 2 is split around the 48-bit
+ * sync/embedded signalling field in the middle of the burst.
+ */
+public class AMBEFrameValidator
+{
+ /**
+ * Maximum combined C0 and C1 bit errors for a valid voice frame. Random 47-bit sequences have a combined error
+ * count of 2 or less with a probability of ~1.3e-4.
+ */
+ public static final int MAX_ERRORS = 2;
+
+ private static final int[] VECTOR_C0 = {0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 1, 5,
+ 9, 13, 17, 21};
+ private static final int[] VECTOR_C1 = {25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 2, 6, 10, 14, 18, 22, 26,
+ 30, 34, 38, 42};
+ private static final int[] FRAME_OFFSETS = {24, 96, 216};
+ private static final int GOLAY_23_GENERATOR = 0xC75;
+ private static final int[][] C0_INDICES = new int[3][VECTOR_C0.length];
+ private static final int[][] C1_INDICES = new int[3][VECTOR_C1.length];
+ //Minimum weight error pattern for each Golay(23,12) syndrome (perfect code: all syndromes have weight <= 3)
+ private static final int[] ERROR_PATTERNS = new int[2048];
+ //C1 descrambling sequence for each 12-bit C0 data (u0) seed value, MSB first
+ private static final int[] SCRAMBLE_SEQUENCES = new int[4096];
+
+ static
+ {
+ for(int frame = 0; frame < 3; frame++)
+ {
+ for(int x = 0; x < VECTOR_C0.length; x++)
+ {
+ C0_INDICES[frame][x] = getBurstIndex(frame, VECTOR_C0[x]);
+ }
+
+ for(int x = 0; x < VECTOR_C1.length; x++)
+ {
+ C1_INDICES[frame][x] = getBurstIndex(frame, VECTOR_C1[x]);
+ }
+ }
+
+ //Golay(23,12) is perfect: the 2048 error patterns of weight 0 - 3 map one-to-one onto the 2048 syndromes.
+ for(int i = 0; i < 23; i++)
+ {
+ ERROR_PATTERNS[getSyndrome(1 << i)] = 1 << i;
+
+ for(int j = i + 1; j < 23; j++)
+ {
+ ERROR_PATTERNS[getSyndrome((1 << i) | (1 << j))] = (1 << i) | (1 << j);
+
+ for(int k = j + 1; k < 23; k++)
+ {
+ int pattern = (1 << i) | (1 << j) | (1 << k);
+ ERROR_PATTERNS[getSyndrome(pattern)] = pattern;
+ }
+ }
+ }
+
+ for(int seed = 0; seed < 4096; seed++)
+ {
+ int pr = 16 * seed;
+ int sequence = 0;
+
+ for(int x = 0; x < 23; x++)
+ {
+ pr = (173 * pr + 13849) % 65536;
+ sequence = (sequence << 1) | (pr >= 32768 ? 1 : 0);
+ }
+
+ SCRAMBLE_SEQUENCES[seed] = sequence;
+ }
+ }
+
+ private AMBEFrameValidator()
+ {
+ }
+
+ /**
+ * Burst bit index for the voice frame bit index.
+ * @param frame index 0-2
+ * @param bit index 0-71 within the (interleaved) voice frame
+ * @return bit index within the 288-bit burst
+ */
+ private static int getBurstIndex(int frame, int bit)
+ {
+ //Frame 2 is split around the 48-bit sync/embedded signalling field (burst bits 132 - 179)
+ if(frame == 1 && bit >= 36)
+ {
+ return FRAME_OFFSETS[frame] + bit + 48;
+ }
+
+ return FRAME_OFFSETS[frame] + bit;
+ }
+
+ /**
+ * Golay(23,12) syndrome for the 23-bit word where the MSB (bit 22) is the first codeword bit.
+ */
+ private static int getSyndrome(int word)
+ {
+ for(int bit = 22; bit >= 11; bit--)
+ {
+ if((word & (1 << bit)) != 0)
+ {
+ word ^= GOLAY_23_GENERATOR << (bit - 11);
+ }
+ }
+
+ return word;
+ }
+
+ /**
+ * Extracts the indexed bits from the message as an integer value, with the first index as the MSB.
+ */
+ private static int getValue(BinaryMessage message, int[] indices)
+ {
+ int value = 0;
+
+ for(int index : indices)
+ {
+ value = (value << 1) | (message.get(index) ? 1 : 0);
+ }
+
+ return value;
+ }
+
+ /**
+ * Combined C0 and C1 bit error count for the voice frame, where C0 is decoded first so that the corrected C0 data
+ * bits can seed the C1 descrambling sequence. C0 errors include the extended Golay parity bit, so an even weight
+ * C0 error pattern (ie 4 errors) that Golay(23,12) mis-corrects is counted as 4 errors.
+ * @param message containing the 288-bit voice burst
+ * @param frame index 0-2
+ * @return combined C0 and C1 bit error count.
+ */
+ public static int getErrorCount(BinaryMessage message, int frame)
+ {
+ int c0 = getValue(message, C0_INDICES[frame]);
+ int golay = c0 >>> 1;
+ int pattern = ERROR_PATTERNS[getSyndrome(golay)];
+ int corrected = golay ^ pattern;
+ int errors = Integer.bitCount(pattern);
+
+ //Extended Golay(24,12) parity bit provides even parity across the 24-bit codeword
+ if(((Integer.bitCount(corrected) + (c0 & 1)) & 1) != 0)
+ {
+ errors++;
+ }
+
+ //Short circuit when C0 alone exceeds the max error count.
+ if(errors > MAX_ERRORS)
+ {
+ return errors;
+ }
+
+ int c1 = getValue(message, C1_INDICES[frame]) ^ SCRAMBLE_SEQUENCES[corrected >>> 11];
+ return errors + Integer.bitCount(ERROR_PATTERNS[getSyndrome(c1)]);
+ }
+
+ /**
+ * Indicates if at least one of the three voice frames in the burst is valid, checking each frame in turn until a
+ * valid frame is found.
+ * @param message containing the 288-bit voice burst
+ * @return true if at least one voice frame has a combined C0 and C1 error count within the max error count.
+ */
+ public static boolean isValid(BinaryMessage message)
+ {
+ for(int frame = 0; frame < 3; frame++)
+ {
+ if(getErrorCount(message, frame) <= MAX_ERRORS)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceMessage.java b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceMessage.java
index 1385f3e2..e66f6e6e 100644
--- a/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceMessage.java
+++ b/src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/VoiceMessage.java
@@ -40,9 +40,10 @@ public abstract class VoiceMessage extends DMRBurst
public static int[] FRAME_1_IV_FRAGMENT = new int[]{95, 91, 87, 83};
public static int[] FRAME_2_IV_FRAGMENT = new int[]{215, 211, 207, 203};
public static int[] FRAME_3_IV_FRAGMENT = new int[]{287, 283, 279, 275};
+ private Boolean mValid;
/**
- * DMR message frame. This message is comprised of a 24-bit prefix and a 264-bit message frame. Outbound base
+ * DMR message frame. This message comprises a 24-bit prefix and a 264-bit message frame. Outbound base
* station frames transmit a Common Announcement Channel (CACH) in the 24-bit prefix, whereas Mobile inbound frames
* do not use the 24-bit prefix.
*
@@ -73,8 +74,8 @@ public abstract class VoiceMessage extends DMRBurst
{
frame_2[i] = getMessage().getByte(96 + i * 8);
}
- // 4 bits and 4 bits
- frame_2[4] = (byte)((getMessage().getByte(128) & 0xF0) | (getMessage().getByte(180) >> 4));
+ // 4 bits and 4 bits. Note: mask the shifted (signed) byte to prevent sign extension into the upper 4 bits.
+ frame_2[4] = (byte)((getMessage().getByte(128) & 0xF0) | ((getMessage().getByte(180) >> 4) & 0x0F));
// copy last 4 byte
for(int i = 0; i < 4; i++)
{
@@ -109,10 +110,20 @@ public abstract class VoiceMessage extends DMRBurst
return fragments;
}
+ /**
+ * Indicates if the voice burst is valid, meaning that at least one of the three AMBE voice frames passes error
+ * detection (see AMBEFrameValidator). Frames are checked in turn until a valid frame is found and the result is
+ * cached.
+ */
@Override
public boolean isValid()
{
- return true;
+ if(mValid == null)
+ {
+ mValid = AMBEFrameValidator.isValid(getMessage());
+ }
+
+ return mValid;
}
@Override
diff --git a/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidatorTest.java b/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidatorTest.java
new file mode 100644
index 00000000..d4f3c19c
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidatorTest.java
@@ -0,0 +1,178 @@
+/*
+ * *****************************************************************************
+ * 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.voice;
+
+import io.github.dsheirer.bits.CorrectedBinaryMessage;
+import io.github.dsheirer.module.decode.dmr.sync.DMRSyncPattern;
+import java.util.List;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for AMBE voice frame error detection.
+ */
+public class AMBEFrameValidatorTest
+{
+ //Base station voice frame A burst from a recording where JMBE decodes all three voice frames with zero errors.
+ private static final String VOICE_BURST = "5A3AC4B9E881526173002A6BB9E881526755FD7DF75F7173002A6BB9E881526173002A6B";
+ //Burst bit indices of the first C0 bits (voice frame bits 0, 4, 8) and first C1 bit (voice frame bit 25) of
+ //frame 1, which starts at burst bit 24.
+ private static final int FRAME_1_C0_BIT_0 = 24;
+ private static final int FRAME_1_C0_BIT_1 = 28;
+ private static final int FRAME_1_C0_BIT_2 = 32;
+ private static final int FRAME_1_C1_BIT_0 = 49;
+
+ private static CorrectedBinaryMessage getBurst()
+ {
+ return CorrectedBinaryMessage.loadHex(VOICE_BURST);
+ }
+
+ @Test
+ void errorFreeBurst()
+ {
+ CorrectedBinaryMessage burst = getBurst();
+
+ for(int frame = 0; frame < 3; frame++)
+ {
+ assertEquals(0, AMBEFrameValidator.getErrorCount(burst, frame), "Frame " + frame);
+ }
+
+ assertTrue(AMBEFrameValidator.isValid(burst));
+ }
+
+ @Test
+ void errorsAreCounted()
+ {
+ CorrectedBinaryMessage burst = getBurst();
+ burst.flip(FRAME_1_C0_BIT_0);
+ assertEquals(1, AMBEFrameValidator.getErrorCount(burst, 0));
+ burst.flip(FRAME_1_C1_BIT_0);
+ assertEquals(2, AMBEFrameValidator.getErrorCount(burst, 0));
+ burst.flip(FRAME_1_C0_BIT_1);
+ assertEquals(3, AMBEFrameValidator.getErrorCount(burst, 0));
+ }
+
+ @Test
+ void fourC0ErrorsAreDetected()
+ {
+ CorrectedBinaryMessage burst = getBurst();
+ burst.flip(FRAME_1_C0_BIT_0);
+ burst.flip(FRAME_1_C0_BIT_1);
+ burst.flip(FRAME_1_C0_BIT_2);
+ burst.flip(FRAME_1_C0_BIT_2 + 4);
+ assertTrue(AMBEFrameValidator.getErrorCount(burst, 0) > AMBEFrameValidator.MAX_ERRORS);
+ }
+
+ @Test
+ void validWhenAnyFrameIsValid()
+ {
+ CorrectedBinaryMessage burst = getBurst();
+
+ //Corrupt the C0 vector of each frame in turn (4 bit errors), verifying a single valid frame is sufficient.
+ int[] frameOffsets = {24, 96, 216};
+
+ for(int frame = 0; frame < 3; frame++)
+ {
+ for(int bit = 0; bit < 16; bit += 4)
+ {
+ burst.flip(frameOffsets[frame] + bit);
+ }
+
+ assertTrue(AMBEFrameValidator.getErrorCount(burst, frame) > AMBEFrameValidator.MAX_ERRORS);
+ assertEquals(frame < 2, AMBEFrameValidator.isValid(burst), "Corrupted frames: " + (frame + 1));
+ }
+ }
+
+ @Test
+ void randomBurstsAreRarelyValid()
+ {
+ Random random = new Random(1234);
+ int valid = 0;
+ int trials = 20000;
+
+ for(int trial = 0; trial < trials; trial++)
+ {
+ CorrectedBinaryMessage burst = new CorrectedBinaryMessage(288);
+
+ for(int bit = 0; bit < 288; bit++)
+ {
+ if(random.nextBoolean())
+ {
+ burst.set(bit);
+ }
+ }
+
+ if(AMBEFrameValidator.isValid(burst))
+ {
+ valid++;
+ }
+ }
+
+ //Expected false valid rate is ~3 x 1.3e-4 (ie ~8 of 20,000)
+ assertTrue(valid < 30, "Random bursts detected as valid: " + valid);
+ }
+
+ @Test
+ void voiceMessageValidity()
+ {
+ assertTrue(new VoiceAMessage(DMRSyncPattern.BASE_STATION_VOICE, getBurst(), null, 0, 1).isValid());
+
+ CorrectedBinaryMessage corrupted = getBurst();
+ int[] frameOffsets = {24, 96, 216};
+
+ for(int offset : frameOffsets)
+ {
+ for(int bit = 0; bit < 16; bit += 4)
+ {
+ corrupted.flip(offset + bit);
+ }
+ }
+
+ assertFalse(new VoiceAMessage(DMRSyncPattern.BASE_STATION_VOICE, corrupted, null, 0, 1).isValid());
+ }
+
+ /**
+ * Verifies the AMBE frames match the burst bits, specifically for frame 2 where bits 32-35 are sourced from burst
+ * bits 128-131 and bits 36-39 from burst bits 180-183, with burst bit 180 set (ie a negative signed byte).
+ */
+ @Test
+ void ambeFramesMatchBurstBits()
+ {
+ CorrectedBinaryMessage burst = getBurst();
+ burst.set(180);
+ burst.clear(128, 132);
+ List frames = new VoiceAMessage(DMRSyncPattern.BASE_STATION_VOICE, burst, null, 0, 1).getAMBEFrames();
+ int[] frameOffsets = {24, 96, 216};
+
+ for(int frame = 0; frame < 3; frame++)
+ {
+ for(int bit = 0; bit < 72; bit++)
+ {
+ int burstIndex = frameOffsets[frame] + bit + ((frame == 1 && bit >= 36) ? 48 : 0);
+ boolean frameBit = (frames.get(frame)[bit / 8] & (0x80 >> (bit % 8))) != 0;
+ assertEquals(burst.get(burstIndex), frameBit, "Frame " + frame + " bit " + bit);
+ }
+ }
+ }
+}