From 02c62d012fe88ea581f05c6082af12ca7adf65dc Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Mon, 9 Nov 2026 03:49:58 -0500 Subject: [PATCH] #2557 DMR improved error detection in voice frames and voice events. (#2608) Co-authored-by: denny --- .../dsp/c4fm/C4FMSymbolProcessor.java | 20 +- .../module/decode/dmr/DMRDecoderState.java | 5 +- .../module/decode/dmr/DMRSymbolProcessor.java | 12 + .../decode/dmr/audio/DMRAudioModule.java | 7 + .../dmr/message/voice/AMBEFrameValidator.java | 207 ++++++++++++++++++ .../dmr/message/voice/VoiceMessage.java | 19 +- .../message/voice/AMBEFrameValidatorTest.java | 178 +++++++++++++++ 7 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidator.java create mode 100644 src/test/java/io/github/dsheirer/module/decode/dmr/message/voice/AMBEFrameValidatorTest.java 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); + } + } + } +}