Compare commits

..

1 Commits

Author SHA1 Message Date
Denny Sheirer
02c62d012f #2557 DMR improved error detection in voice frames and voice events. (#2608)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-09 03:49:58 -05:00
7 changed files with 441 additions and 7 deletions

View File

@@ -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());
}
}

View File

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

View File

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

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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;
}
}

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<byte[]> 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);
}
}
}
}