#2518 P25P1 LSM Decoder enhancements. (#2523)

* Uses same shared architecture and symbol processor pattern as other C4FM decoders.
* Adds soft symbol processing and sync detection.
* Removes RRC filter and replaces with improved channel filter.
* Viterbi soft symbol decoding with demodulator reliability weighting
* Improved sync reacquisition in low SNR
* Improved handling for phase wrapping and interpolation
* NAC Tracker updated to track by NAC count
* Native buffers returning partial buffers
* Message processor uses only valid LDU1 words
* Reduced phantom TDU messages
* Tuner/Channe frequency error corrections don't reset the symbol trackers and fix possible divide by zero error
* LSM timing error detector now updates per symbol block size instead of sample buffer size - faster convergence
* New unit tests
* Resolves several issues, including a lagging sync detect offset issue in the recently updated P25P1 C4FM decoder.
* Consolidate filter designers for each protocol back into the FilterFactory class.
* DMR, P25 Phase 1 C4FM and LSM, and P25 Phase 2 decoders design the baseband filter once and share the taps across the I and Q filters.

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-10-29 04:17:47 -04:00
committed by GitHub
parent f2c98cdb40
commit 6b9cd2d317
42 changed files with 3586 additions and 2767 deletions

View File

@@ -0,0 +1,109 @@
/*
* *****************************************************************************
* 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.buffer;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the float native buffer.
*/
public class FloatNativeBufferTest
{
private static final int SAMPLE_COUNT = 5000;
/**
* Creates interleaved complex samples where each value is unique and non-zero.
*/
private static float[] getSamples()
{
float[] samples = new float[SAMPLE_COUNT * 2];
for(int x = 0; x < samples.length; x++)
{
samples[x] = x + 1;
}
return samples;
}
/**
* Test: the complex samples iterator returns every sample exactly once, with a final chunk that holds the remaining
* samples rather than padding them to the full chunk length with zero valued samples.
*/
@Test
void testComplexSamplesIterator()
{
float[] samples = getSamples();
List<Integer> lengths = new ArrayList<>();
int pointer = 0;
Iterator<ComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iterator();
while(iterator.hasNext())
{
ComplexSamples chunk = iterator.next();
lengths.add(chunk.i().length);
for(int x = 0; x < chunk.i().length; x++)
{
assertEquals(samples[pointer++], chunk.i()[x], "Inphase sample " + (pointer / 2));
assertEquals(samples[pointer++], chunk.q()[x], "Quadrature sample " + (pointer / 2));
}
}
assertEquals(samples.length, pointer, "Sample count");
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
}
/**
* Test: the interleaved complex samples iterator returns every sample exactly once, without zero padding.
*/
@Test
void testInterleavedComplexSamplesIterator()
{
float[] samples = getSamples();
List<Integer> lengths = new ArrayList<>();
int pointer = 0;
Iterator<InterleavedComplexSamples> iterator = new FloatNativeBuffer(samples, 0L, 0.0f).iteratorInterleaved();
while(iterator.hasNext())
{
float[] chunk = iterator.next().samples();
lengths.add(chunk.length / 2);
for(float sample : chunk)
{
assertEquals(samples[pointer], sample, "Sample " + pointer);
pointer++;
}
}
assertEquals(samples.length, pointer, "Sample count");
assertEquals(List.of(2048, 2048, 904), lengths, "Chunk lengths");
}
}

View File

@@ -0,0 +1,95 @@
/*
* *****************************************************************************
* 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.dsp.fm;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the FM discriminator soft symbol reliability.
*/
public class FMDiscriminatorReliabilityTest
{
private static final int BUFFER_LENGTH = 256;
/**
* Test: inverse power filter taps are the squared differences of the post-discriminator filter taps.
*/
@Test
void testInversePowerFilterTaps()
{
float[] taps = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.25f, 0.5f, 0.25f});
assertArrayEquals(new float[]{0.0625f, 0.0625f, 0.0625f, 0.0625f}, taps, 1e-7f, "Taps");
//An integrate and dump (flat) filter depends only on the samples at either end of the filter span
float[] flat = FMDiscriminatorReliability.getInversePowerFilterTaps(new float[]{0.2f, 0.2f, 0.2f, 0.2f, 0.2f});
assertArrayEquals(new float[]{0.04f, 0f, 0f, 0f, 0f, 0.04f}, flat, 1e-7f, "Flat filter taps");
}
/**
* Test: each reliability is 1 / sum(g[k]^2 / |x[n - k]|^2), including across the sample buffer boundary.
*/
@Test
void testReliabilities()
{
float[] filter = {0.1f, 0.2f, 0.4f, 0.2f, 0.1f};
float[] g2 = FMDiscriminatorReliability.getInversePowerFilterTaps(filter);
FMDiscriminatorReliability reliability = new FMDiscriminatorReliability(filter);
float[] amplitudes = new float[2 * BUFFER_LENGTH];
for(int x = 0; x < amplitudes.length; x++)
{
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
}
float[] reliabilities = new float[amplitudes.length];
for(int buffer = 0; buffer < 2; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
//Rotate the phase to show that the reliability depends only on the sample power
double phase = x * 0.5;
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
i[x] = (float)(amplitude * Math.cos(phase));
q[x] = (float)(amplitude * Math.sin(phase));
}
System.arraycopy(reliability.calculate(i, q), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
}
for(int x = g2.length; x < amplitudes.length; x++)
{
double sum = 0.0;
for(int k = 0; k < g2.length; k++)
{
sum += g2[k] / (amplitudes[x - k] * amplitudes[x - k]);
}
assertEquals(1.0 / sum, reliabilities[x], 1e-4 / sum, "Reliability at " + x);
}
}
}

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.edac.trellis;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.dsp.symbol.Dibit;
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.assertTrue;
/**
* JUnit tests for the P25 1/2 rate trellis soft decision Viterbi decoder.
*/
public class ViterbiDecoder_1_2_P25Test
{
private static final int INPUT_DIBITS = 48;
//Faded symbol spacing for the reliability weighting test (ie one third of the symbols are faded)
private static final int SPACING = 3;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Encodes the input dibit values (plus a flushing zero) into 98 ideal soft symbols.
*/
private static float[] encode(int[] inputs)
{
float[] symbols = new float[(inputs.length + 1) * 2];
int state = 0;
for(int step = 0; step <= inputs.length; step++)
{
int input = step < inputs.length ? inputs[step] : 0;
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
symbols[2 * step] = DIBITS[output >> 2].getIdealPhase();
symbols[2 * step + 1] = DIBITS[output & 3].getIdealPhase();
state = input;
}
return symbols;
}
/**
* Counts the decoded message bits that differ from the input dibit values.
*/
private static int countBitErrors(int[] inputs, CorrectedBinaryMessage decoded)
{
int errors = 0;
for(int step = 0; step < inputs.length; step++)
{
if(decoded.get(2 * step) != ((inputs[step] & 2) == 2))
{
errors++;
}
if(decoded.get(2 * step + 1) != ((inputs[step] & 1) == 1))
{
errors++;
}
}
return errors;
}
private static int[] randomInputs(Random random)
{
int[] inputs = new int[INPUT_DIBITS];
for(int x = 0; x < inputs.length; x++)
{
inputs[x] = random.nextInt(4);
}
return inputs;
}
/**
* Test: ideal soft symbols decode without error in both the linear (C4FM) and phase wrapped (LSM) modes.
*/
@Test
void testIdealSymbols()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(1);
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols, true)), "Phase wrapped");
}
}
/**
* Test: phase wrapped decoding measures the soft symbol error around the circle, so outer symbols that noise
* pushed across the +/- PI phase wrap are decoded correctly.
*/
@Test
void testOuterSymbolsAcrossPhaseWrap()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(2);
int linearErrors = 0;
int wrappedErrors = 0;
for(int trial = 0; trial < 200; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
int outerCount = 0;
for(int x = 0; x < symbols.length; x++)
{
//Push every third outer (+/- 3PI/4) symbol 0.85 radians toward and across the phase wrap
if(Math.abs(symbols[x]) > Math.PI / 2 && (outerCount++ % 3) == 0)
{
double pushed = symbols[x] + Math.signum(symbols[x]) * 0.85;
symbols[x] = (float)(pushed - 2.0 * Math.PI * Math.round(pushed / (2.0 * Math.PI)));
}
}
linearErrors += countBitErrors(inputs, decoder.decodeSoft(symbols));
wrappedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
}
assertEquals(0, wrappedErrors, "Phase wrapped bit errors");
assertTrue(wrappedErrors <= linearErrors, "Phase wrapped decoding no worse than linear decoding");
}
/**
* Test: reliability weighting decodes corrupted symbols that have low reliability that unweighted soft decision
* decoding fails to decode. The corrupted symbols are spaced across the block, the way the deinterleaver spreads
* the symbols of a transmitted envelope fade.
*/
@Test
void testReliabilityWeightedFade()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(4);
int unweightedErrors = 0;
int weightedErrors = 0;
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
float[] reliabilities = new float[symbols.length];
java.util.Arrays.fill(reliabilities, 1.0f);
//Replace every SPACING symbol with a random phase (ie a deep fade) and mark it with low reliability
for(int x = random.nextInt(SPACING); x < symbols.length; x += SPACING)
{
symbols[x] = (float)((random.nextDouble() * 2.0 - 1.0) * Math.PI);
reliabilities[x] = 0.01f;
}
unweightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, true));
weightedErrors += countBitErrors(inputs, decoder.decodeSoft(symbols, reliabilities, true));
}
assertEquals(0, weightedErrors, "Reliability weighted bit errors");
assertTrue(unweightedErrors > 0, "Unweighted decoding fails to decode the faded symbols");
}
/**
* Test: unavailable or unusable reliabilities (null, NaN or all zero) decode the same as unweighted decoding.
*/
@Test
void testUnusableReliabilities()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(5);
for(int trial = 0; trial < 20; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
for(int x = 0; x < symbols.length; x++)
{
symbols[x] += (float)(random.nextGaussian() * 0.5);
}
CorrectedBinaryMessage unweighted = decoder.decodeSoft(symbols, true);
float[] nan = new float[symbols.length];
java.util.Arrays.fill(nan, 1.0f);
nan[7] = Float.NaN;
assertEquals(unweighted, decoder.decodeSoft(symbols, null, true), "Null reliabilities");
assertEquals(unweighted, decoder.decodeSoft(symbols, nan, true), "NaN reliability");
assertEquals(unweighted, decoder.decodeSoft(symbols, new float[symbols.length], true), "Zero reliabilities");
}
}
/**
* Test: soft symbols that are not phase wrapped (C4FM) with outer symbols beyond +/- PI decode correctly with
* linear error measurement.
*/
@Test
void testUnwrappedOuterSymbolsBeyondPi()
{
ViterbiDecoder_1_2_P25 decoder = new ViterbiDecoder_1_2_P25();
Random random = new Random(3);
for(int trial = 0; trial < 50; trial++)
{
int[] inputs = randomInputs(random);
float[] symbols = encode(inputs);
for(int x = 0; x < symbols.length; x++)
{
if(Math.abs(symbols[x]) > Math.PI / 2)
{
symbols[x] += (float)(Math.signum(symbols[x]) * 0.85);
}
}
assertEquals(0, countBitErrors(inputs, decoder.decodeSoft(symbols)), "Linear");
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the NAC tracker.
*/
public class NACTrackerTest
{
/**
* Test: the dominant NAC needs the minimum number of observations.
*/
@Test
void testDominantNAC()
{
NACTracker tracker = new NACTracker();
assertEquals(0, tracker.getTrackedNAC(), "Empty");
tracker.track(0x293);
tracker.track(0x293);
tracker.track(0x111);
assertEquals(0, tracker.getTrackedNAC(), "Below the minimum observations");
tracker.track(0x293);
assertEquals(0x293, tracker.getTrackedNAC(), "Dominant");
}
/**
* Test: when a fourth NAC is observed, the least recently observed NAC is evicted, independent of the system clock.
* All of the observations occur within the same millisecond, as they do when a recording decodes faster than real
* time.
*/
@Test
void testLeastRecentlyObservedEvicted()
{
for(int repeat = 0; repeat < 100; repeat++)
{
NACTracker tracker = new NACTracker();
//0x293 is the most observed, but the least recently observed when 0x444 arrives
for(int x = 0; x < 5; x++)
{
tracker.track(0x293);
}
for(int x = 0; x < 4; x++)
{
tracker.track(0x111);
}
tracker.track(0x222);
tracker.track(0x444);
assertEquals(0x111, tracker.getTrackedNAC(), "Least recently observed NAC evicted, repeat " + repeat);
}
}
}

View File

@@ -0,0 +1,409 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.symbol.Dibit;
import io.github.dsheirer.edac.CRCP25;
import io.github.dsheirer.edac.bch.BCH_63_16_23_P25;
import io.github.dsheirer.edac.trellis.P25_1_2_Node;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.TSBKMessage;
import io.github.dsheirer.sample.complex.ComplexSamples;
import java.util.ArrayList;
import java.util.BitSet;
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.assertTrue;
/**
* End-to-end JUnit tests for the P25 Phase 1 LSM decoder: a synthetic LSM (CQPSK) control channel carrying single
* block TSBK frames is generated and decoded, and each valid decoded TSBK is checked against the transmitted TSBKs.
*/
public class P25P1DecoderLSMTest
{
private static final double SAMPLE_RATE = 48000.0;
private static final double SYMBOL_RATE = 4800.0;
private static final int FRAME_COUNT = 150;
private static final int NAC = 0x293;
//Single block TSBK frame: 180 dibits (360 bits) including a status dibit every 36th dibit and null padding
private static final int FRAME_DIBITS = 180;
private static final int TSBK_DUID = 7;
private static final int OPCODE_RFSS_STATUS_BROADCAST = 58;
//Raised cosine transmit pulse
private static final double ROLLOFF = 0.2;
private static final int PULSE_SPAN_SYMBOLS = 8;
private static final int BUFFER_SIZE = 2048;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Test: a clean signal decodes every TSBK frame, with no invalid messages.
*/
@Test
void testCleanSignal()
{
Result result = decode(0.0, 0.0, Double.NaN, 1, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages");
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
}
/**
* Test: a carrier offset within the +/- 600 Hz fourth power offset estimator range and a 100 ppm symbol rate error
* decode every TSBK frame.
*/
@Test
void testCarrierOffsetAndSymbolRateError()
{
Result result = decode(300.0, 100.0, Double.NaN, 2, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages");
assertEquals(FRAME_COUNT, result.valid(), "Valid messages");
}
/**
* Test: carrier offsets beyond the fourth power offset estimator range are resolved by the offset ambiguity search,
* after which every TSBK frame decodes. The search steps toward the adjacent offset range nearest to the estimate,
* so offsets on either side acquire after one search dwell period (~11 frames).
*/
@Test
void testOffsetAmbiguitySearch()
{
for(double carrierOffset : new double[]{700.0, -700.0, 900.0, -900.0})
{
Result result = decode(carrierOffset, -100.0, Double.NaN, 3, BUFFER_SIZE);
assertEquals(0, result.invalid(), "Invalid messages at " + carrierOffset + " Hz");
assertTrue(result.first() <= 16, "Acquisition at " + carrierOffset + " Hz: " + result.first() + " frames");
assertEquals(0, result.missed(), "Missed frames after acquisition at " + carrierOffset + " Hz");
}
}
/**
* Test: acquisition of offsets near the edge of the fourth power offset estimator range is fast and independent of
* the sample buffer size. The estimator previously updated once per buffer, so a -550 Hz offset took 36 frames to
* acquire with 2048 sample buffers and 55 frames with 8192 sample buffers.
*/
@Test
void testAcquisitionIndependentOfBufferSize()
{
for(int bufferSize : new int[]{512, 2048, 8192})
{
for(double carrierOffset : new double[]{550.0, -550.0})
{
Result result = decode(carrierOffset, 50.0, Double.NaN, 5, bufferSize);
String condition = carrierOffset + " Hz with " + bufferSize + " sample buffers";
assertEquals(0, result.invalid(), "Invalid messages at " + condition);
assertTrue(result.first() <= 5, "Acquisition at " + condition + ": " + result.first() + " frames");
assertEquals(0, result.missed(), "Missed frames after acquisition at " + condition);
}
}
}
/**
* Test: with additive white gaussian noise at 12 dB in-band (+/- 3 kHz) carrier to noise, nearly all TSBK frames
* decode and every valid TSBK matches a transmitted TSBK.
*/
@Test
void testNoise()
{
Result result = decode(200.0, 50.0, 12.0, 4, BUFFER_SIZE);
assertTrue(result.valid() >= FRAME_COUNT - 5, "Valid messages: " + result.valid());
}
/**
* Generates and decodes the synthetic signal.
* @param carrierOffset in Hertz
* @param symbolRateError in parts per million
* @param carrierToNoise in dB, in-band (+/- 3 kHz), or NaN for no noise
* @param seed for the random TSBK payloads and noise
* @param bufferSize of the sample buffers delivered to the decoder (even)
* @return decoded message counts, the index of the first decoded TSBK frame (the frame count when
* none decode) and the count of TSBK frames that
* didn't decode after the first decoded TSBK frame.
*/
private static Result decode(double carrierOffset, double symbolRateError, double carrierToNoise, long seed,
int bufferSize)
{
Random random = new Random(seed);
List<BitSet> tsbks = new ArrayList<>();
Set<BitSet> transmitted = new HashSet<>();
List<Dibit> dibits = new ArrayList<>();
for(int frame = 0; frame < FRAME_COUNT; frame++)
{
BitSet tsbk = getTSBK(random);
tsbks.add(tsbk);
transmitted.add(tsbk);
dibits.addAll(getFrame(tsbk));
}
//Trailing frame that isn't checked: the decoder dispatches a message after its trailing symbols are processed
dibits.addAll(getFrame(getTSBK(random)));
float[][] samples = modulate(dibits, carrierOffset, symbolRateError, carrierToNoise, random);
List<TSBKMessage> received = new ArrayList<>();
P25P1DecoderLSM decoder = new P25P1DecoderLSM();
decoder.setMessageListener((IMessage message) -> {
if(message instanceof TSBKMessage tsbk)
{
received.add(tsbk);
}
});
decoder.start();
decoder.setSampleRate(SAMPLE_RATE);
for(int offset = 0; offset < samples[0].length; offset += bufferSize)
{
int length = Math.min(bufferSize, samples[0].length - offset);
float[] i = new float[length];
float[] q = new float[length];
System.arraycopy(samples[0], offset, i, 0, length);
System.arraycopy(samples[1], offset, q, 0, length);
decoder.receive(new ComplexSamples(i, q, 0L));
}
decoder.stop();
int valid = 0;
int invalid = 0;
Set<BitSet> decoded = new HashSet<>();
for(TSBKMessage tsbk : received)
{
if(tsbk.isValid())
{
BitSet bits = new BitSet();
for(int x = 0; x < 80; x++)
{
bits.set(x, tsbk.getMessage().get(x));
}
//A valid TSBK that doesn't match a transmitted TSBK is a CRC false positive
assertTrue(transmitted.contains(bits), "Decoded TSBK was not transmitted");
assertTrue(decoded.add(bits), "Decoded TSBK was duplicated");
valid++;
}
else
{
invalid++;
}
}
int first = tsbks.size();
int missed = 0;
for(int x = 0; x < tsbks.size(); x++)
{
if(decoded.contains(tsbks.get(x)))
{
first = Math.min(first, x);
}
else if(first < x)
{
missed++;
}
}
return new Result(valid, invalid, first, missed);
}
/**
* Creates a random single block (last block) TSBK, 80 message bits.
*/
private static BitSet getTSBK(Random random)
{
BitSet tsbk = new BitSet();
tsbk.set(0); //Last block
setValue(tsbk, 2, 6, OPCODE_RFSS_STATUS_BROADCAST);
for(int x = 16; x < 80; x++)
{
tsbk.set(x, random.nextBoolean());
}
return tsbk;
}
/**
* Creates the frame dibits for the TSBK: sync, NID, interleaved trellis encoded TSBK and null padding, with a
* status dibit at every 36th dibit.
*/
private static List<Dibit> getFrame(BitSet tsbk)
{
List<Dibit> data = new ArrayList<>();
addBits(data, 0x5575F5FF77FFL, 48);
addBits(data, BCH_63_16_23_P25.encode(NAC, TSBK_DUID) << 1, 64); //64th (parity) bit is zero
data.addAll(encodeTSBK(tsbk));
List<Dibit> frame = new ArrayList<>();
int pointer = 0;
for(int x = 1; x <= FRAME_DIBITS; x++)
{
if(x % 36 == 0)
{
frame.add(Dibit.D01_PLUS_3); //Outbound (repeater) status
}
else
{
frame.add(pointer < data.size() ? data.get(pointer++) : Dibit.D00_PLUS_1);
}
}
return frame;
}
/**
* Adds the CRC-CCITT checksum, 1/2 rate trellis encodes and interleaves the TSBK into 98 dibits.
*/
private static List<Dibit> encodeTSBK(BitSet tsbk)
{
BitSet message = (BitSet)tsbk.clone();
int checksum = 0xFFFF;
for(int x = message.nextSetBit(0); x >= 0 && x < 80; x = message.nextSetBit(x + 1))
{
checksum ^= CRCP25.CCITT_80_CHECKSUMS[x];
}
setValue(message, 80, 16, checksum);
//48 input dibits plus a flushing zero input produce 98 encoded dibits
int[] encoded = new int[98];
int state = 0;
for(int step = 0; step < 49; step++)
{
int input = step < 48 ? (message.get(2 * step) ? 2 : 0) + (message.get(2 * step + 1) ? 1 : 0) : 0;
int output = P25_1_2_Node.TRANSITION_MATRIX[state][input];
encoded[2 * step] = output >> 2;
encoded[2 * step + 1] = output & 3;
state = input;
}
List<Dibit> interleaved = new ArrayList<>();
for(int dibit = 0; dibit < encoded.length; dibit++)
{
interleaved.add(DIBITS[encoded[P25P1Interleave.DATA_DEINTERLEAVE[2 * dibit] / 2]]);
}
return interleaved;
}
/**
* Modulates the dibits as LSM: differential QPSK with raised cosine pulse shaping.
* @return i and q sample arrays
*/
private static float[][] modulate(List<Dibit> dibits, double carrierOffset, double symbolRateError,
double carrierToNoise, Random random)
{
double[] symbolI = new double[dibits.size()];
double[] symbolQ = new double[dibits.size()];
double phase = 0.0;
for(int x = 0; x < dibits.size(); x++)
{
phase += dibits.get(x).getIdealPhase();
symbolI[x] = Math.cos(phase);
symbolQ[x] = Math.sin(phase);
}
double samplesPerSymbol = SAMPLE_RATE / (SYMBOL_RATE * (1.0 + symbolRateError * 1e-6));
//The decimation filter requires an even sample count
int length = (int)((dibits.size() + PULSE_SPAN_SYMBOLS) * samplesPerSymbol) / 2 * 2;
float[] i = new float[length];
float[] q = new float[length];
//Unit power symbols: the in-band noise variance is the in-band fraction of the total noise variance
double noise = Double.isNaN(carrierToNoise) ? 0.0 :
Math.sqrt(Math.pow(10.0, -carrierToNoise / 10.0) * SAMPLE_RATE / 6000.0 / 2.0);
for(int n = 0; n < length; n++)
{
double t = n / samplesPerSymbol - PULSE_SPAN_SYMBOLS / 2.0; //Symbol periods
double sumI = 0.0;
double sumQ = 0.0;
for(int k = (int)Math.ceil(t - PULSE_SPAN_SYMBOLS / 2.0); k <= t + PULSE_SPAN_SYMBOLS / 2.0; k++)
{
if(k >= 0 && k < dibits.size())
{
double pulse = getRaisedCosine(t - k);
sumI += symbolI[k] * pulse;
sumQ += symbolQ[k] * pulse;
}
}
double carrier = 2.0 * Math.PI * carrierOffset * n / SAMPLE_RATE;
double cos = Math.cos(carrier);
double sin = Math.sin(carrier);
i[n] = (float)(sumI * cos - sumQ * sin + noise * random.nextGaussian());
q[n] = (float)(sumI * sin + sumQ * cos + noise * random.nextGaussian());
}
return new float[][]{i, q};
}
/**
* Raised cosine pulse value
* @param t time in symbol periods
*/
private static double getRaisedCosine(double t)
{
double sinc = Math.abs(t) < 1e-9 ? 1.0 : Math.sin(Math.PI * t) / (Math.PI * t);
double denominator = 1.0 - Math.pow(2.0 * ROLLOFF * t, 2.0);
if(Math.abs(denominator) < 1e-9)
{
return Math.PI / 4.0 * sinc;
}
return sinc * Math.cos(Math.PI * ROLLOFF * t) / denominator;
}
/**
* Adds the most significant bits first, as dibits.
*/
private static void addBits(List<Dibit> dibits, long value, int bitCount)
{
for(int bit = bitCount - 2; bit >= 0; bit -= 2)
{
dibits.add(DIBITS[(int)((value >> bit) & 3)]);
}
}
/**
* Sets the value at the bit offset, most significant bit first.
*/
private static void setValue(BitSet bits, int offset, int bitCount, int value)
{
for(int x = 0; x < bitCount; x++)
{
bits.set(offset + x, ((value >> (bitCount - 1 - x)) & 1) == 1);
}
}
private record Result(int valid, int invalid, int first, int missed) {}
}

View File

@@ -0,0 +1,353 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.symbol.Dibit;
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.assertTrue;
/**
* JUnit tests for the P25 Phase 1 LSM differential demodulator and differential integrator filter design.
*/
public class P25P1DifferentialDemodulatorLSMTest
{
private static final int SAMPLE_RATE = 24000;
private static final int SAMPLES_PER_SYMBOL = 5;
private static final int BUFFER_LENGTH = 1024;
private static final Dibit[] DIBITS = {Dibit.D00_PLUS_1, Dibit.D01_PLUS_3, Dibit.D10_MINUS_1, Dibit.D11_MINUS_3};
/**
* Synthetic differential QPSK signal generator. Ramps the phase across each symbol period by the symbol's ideal
* phase so that the phase change across each symbol period is the ideal symbol phase, plus a carrier offset.
*/
private static class SignalGenerator
{
private final Random mRandom = new Random(1);
private final double mOffset; //Radians per sample
private final double mNoise;
private double mPhase = 0.0;
private Dibit mSymbol = Dibit.D00_PLUS_1;
private int mSampleCounter = 0;
private Dibit mPreviousSymbol;
SignalGenerator(double offsetHertz, double noise)
{
mOffset = 2.0 * Math.PI * offsetHertz / SAMPLE_RATE;
mNoise = noise;
}
/**
* Generates the next buffer of samples, recording the symbol completed at each sample (or null).
*/
float[][] next(Dibit[] completedSymbols)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
if(mSampleCounter % SAMPLES_PER_SYMBOL == 0)
{
mPreviousSymbol = mSymbol;
mSymbol = DIBITS[mRandom.nextInt(4)];
completedSymbols[x] = mSampleCounter > 0 ? mPreviousSymbol : null;
}
else
{
completedSymbols[x] = null;
}
i[x] = (float)(Math.cos(mPhase) + mRandom.nextGaussian() * mNoise);
q[x] = (float)(Math.sin(mPhase) + mRandom.nextGaussian() * mNoise);
mPhase += mSymbol.getIdealPhase() / SAMPLES_PER_SYMBOL + mOffset;
mSampleCounter++;
}
return new float[][]{i, q};
}
}
private static double toHertz(float radiansPerSample)
{
return radiansPerSample * SAMPLE_RATE / (2.0 * Math.PI);
}
/**
* Test: the integrator taps span one symbol period with the fractional remainder in the final tap and unity DC gain.
*/
@Test
void testDifferentialIntegratorDesign()
{
float[] taps = FilterFactory.getDifferentialIntegrator(5.2);
assertEquals(6, taps.length, "Tap count");
float sum = 0;
for(float tap : taps)
{
sum += tap;
}
assertEquals(1.0, sum, 1e-5, "DC gain");
assertEquals(1.0 / 5.2, taps[0], 1e-6, "Whole tap");
assertEquals(0.2 / 5.2, taps[5], 1e-6, "Fractional tap");
assertEquals(5, FilterFactory.getDifferentialIntegrator(5.0).length, "Integral samples per symbol");
}
/**
* Test: the carrier offset estimate converges and each symbol period's demodulated sample, scaled to radians per
* symbol, decides the transmitted symbol with the offset removed.
*/
@Test
void testOffsetRemovalAndSymbolRecovery()
{
SignalGenerator generator = new SignalGenerator(300, 0.0);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
int symbolCount = 0;
int errorCount = 0;
for(int buffer = 0; buffer < 400; buffer++)
{
float[][] samples = generator.next(completed);
float[] demodulated = demodulator.demodulate(samples[0], samples[1], true);
//Check symbol decisions after the estimator converges
if(buffer >= 300)
{
for(int x = 0; x < BUFFER_LENGTH; x++)
{
if(completed[x] != null)
{
symbolCount++;
if(Dibit.fromSample(demodulated[x] * SAMPLES_PER_SYMBOL) != completed[x])
{
errorCount++;
}
}
}
}
}
assertEquals(300.0, toHertz(demodulator.getFrequencyOffset()), 5.0, "Offset estimate");
assertTrue(symbolCount > 10000, "Symbol count");
assertEquals(0, errorCount, "Symbol errors");
}
/**
* Test: an offset beyond the +/- 600 Hz fourth power estimator range is found by the ambiguity search while
* unsynchronized, and is held once synchronized.
*/
@Test
void testAmbiguitySearch()
{
SignalGenerator generator = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
boolean resolved = false;
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], false);
//Emulate the symbol processor synchronizing once the offset is resolved
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
}
assertTrue(resolved, "Search resolves the offset");
for(int buffer = 0; buffer < 200; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], true);
}
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 10.0, "Offset held while synchronized");
}
/**
* Test: the ambiguity search holds the resolved offset range across a gap with no signal while unsynchronized (ie
* between transmissions on a traffic channel) so that the next transmission starts in the correct offset range.
*/
@Test
void testSearchHoldsAcrossSignalGap()
{
SignalGenerator generator = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
boolean resolved = false;
for(int buffer = 0; buffer < 1000 && !resolved; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], false);
resolved = Math.abs(toHertz(demodulator.getFrequencyOffset()) + 750) < 20;
}
assertTrue(resolved, "Search resolves the offset");
//Emulate the symbol processor synchronizing to the signal and the framer decoding a valid NID once the offset is
//resolved
for(int buffer = 0; buffer < 20; buffer++)
{
float[][] samples = generator.next(completed);
demodulator.demodulate(samples[0], samples[1], true);
demodulator.offsetVerified();
}
//Gap of ~20,000 symbols (10x the search dwell period) with no signal and no synchronization
Random random = new Random(3);
for(int buffer = 0; buffer < 100; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
i[x] = (float)random.nextGaussian();
q[x] = (float)random.nextGaussian();
}
demodulator.demodulate(i, q, false);
}
assertEquals(-750.0, toHertz(demodulator.getFrequencyOffset()), 20.0, "Offset held across the gap");
}
/**
* Test: symbol processor synchronization without a valid NID decode (ie a false sync detection in an incorrect
* offset range) doesn't hold the ambiguity search in the incorrect offset range for the longer dwell period, while
* a verified offset range is held.
*/
@Test
void testUnverifiedSynchronizationDoesNotHoldSearch()
{
SignalGenerator generatorUnverified = new SignalGenerator(-750, 0.1);
SignalGenerator generatorVerified = new SignalGenerator(-750, 0.1);
P25P1DifferentialDemodulatorLSM unverified = new P25P1DifferentialDemodulatorLSM();
P25P1DifferentialDemodulatorLSM verified = new P25P1DifferentialDemodulatorLSM();
unverified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
verified.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
Dibit[] completed = new Dibit[BUFFER_LENGTH];
//Emulate a sync detection in the initial (incorrect) offset range, with and without a valid NID decode
for(int buffer = 0; buffer < 5; buffer++)
{
float[][] samples = generatorUnverified.next(completed);
unverified.demodulate(samples[0], samples[1], true);
samples = generatorVerified.next(completed);
verified.demodulate(samples[0], samples[1], true);
verified.offsetVerified();
}
//Unsynchronized for ~10,000 symbols: longer than the search dwell and shorter than the verified dwell
for(int buffer = 0; buffer < 50; buffer++)
{
float[][] samples = generatorUnverified.next(completed);
unverified.demodulate(samples[0], samples[1], false);
samples = generatorVerified.next(completed);
verified.demodulate(samples[0], samples[1], false);
}
//The unverified search steps to an adjacent (1200 Hz) offset range while the verified search holds its range
double difference = Math.abs(toHertz(unverified.getFrequencyOffset()) - toHertz(verified.getFrequencyOffset()));
assertTrue(difference > 600, "Unverified offset range search stepped away [" + difference + " Hz]");
}
/**
* Test: each sample's reliability is (|x[n]|^2 * |x[n - sps]|^2) / (|x[n]|^2 + |x[n - sps]|^2), including across the
* sample buffer boundary.
*/
@Test
void testReliabilities()
{
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
float[] amplitudes = new float[2 * BUFFER_LENGTH];
for(int x = 0; x < amplitudes.length; x++)
{
amplitudes[x] = 0.1f + 0.9f * (float)Math.abs(Math.sin(x * 0.37));
}
float[] reliabilities = new float[amplitudes.length];
for(int buffer = 0; buffer < 2; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
//Rotate the phase to show that the reliability depends only on the sample power
double phase = x * 0.5;
float amplitude = amplitudes[buffer * BUFFER_LENGTH + x];
i[x] = (float)(amplitude * Math.cos(phase));
q[x] = (float)(amplitude * Math.sin(phase));
}
demodulator.demodulate(i, q, true);
System.arraycopy(demodulator.getReliabilities(), 0, reliabilities, buffer * BUFFER_LENGTH, BUFFER_LENGTH);
}
for(int x = SAMPLES_PER_SYMBOL; x < amplitudes.length; x++)
{
double current = amplitudes[x] * amplitudes[x];
double delayed = amplitudes[x - SAMPLES_PER_SYMBOL] * amplitudes[x - SAMPLES_PER_SYMBOL];
assertEquals(current * delayed / (current + delayed), reliabilities[x], 1e-5, "Reliability at " + x);
}
}
/**
* Test: the offset estimate doesn't drift when the input is noise.
*/
@Test
void testNoiseDoesNotMoveEstimate()
{
Random random = new Random(2);
P25P1DifferentialDemodulatorLSM demodulator = new P25P1DifferentialDemodulatorLSM();
demodulator.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
for(int buffer = 0; buffer < 2000; buffer++)
{
float[] i = new float[BUFFER_LENGTH];
float[] q = new float[BUFFER_LENGTH];
for(int x = 0; x < BUFFER_LENGTH; x++)
{
i[x] = (float)random.nextGaussian();
q[x] = (float)random.nextGaussian();
}
demodulator.demodulate(i, q, true);
}
assertEquals(0.0, toHertz(demodulator.getFrequencyOffset()), 1e-3, "Offset estimate");
}
}

View File

@@ -0,0 +1,138 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.symbol.Dibit;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the P25 Phase 1 message assembler forced completion of place holder (failed NID) assemblies.
*/
public class P25P1MessageAssemblerTest
{
private static final P25P1DataUnitID TDU = P25P1DataUnitID.TERMINATOR_DATA_UNIT;
private static final P25P1DataUnitID TDULC = P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL;
private static final P25P1DataUnitID TSBK = P25P1DataUnitID.TRUNKING_SIGNALING_BLOCK_1;
/**
* Creates a place holder assembly with the bit length.
*/
private static P25P1MessageAssembler getPlaceHolder(int bitLength)
{
P25P1MessageAssembler assembler = new P25P1MessageAssembler(0x293, P25P1DataUnitID.PLACE_HOLDER);
for(int x = 0; x < bitLength / 2; x++)
{
assembler.receive(Dibit.D01_PLUS_3);
}
return assembler;
}
/**
* Forces completion of a place holder with the bit length.
* @return the identified data unit ID
*/
private static P25P1DataUnitID forceCompletion(int bitLength, P25P1DataUnitID previous, P25P1DataUnitID next)
{
P25P1MessageAssembler assembler = getPlaceHolder(bitLength);
assembler.forceCompletion(previous, next);
return assembler.getDataUnitID();
}
/**
* Test: a place holder that's shorter than a missed TDU frame (ie inter-frame padding or timing slack ahead of the
* next sync) isn't identified as a TDU, even when followed by an LDU1 (HDU resolution) or a TSBK.
*/
@Test
void testShortPlaceHolderDiscarded()
{
for(int length : new int[]{0, 2, 28, 100, 126})
{
P25P1MessageAssembler assembler = getPlaceHolder(length);
assertEquals(0, assembler.forceCompletion(TDULC, TDULC), "Dropped bits at " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(length, P25P1DataUnitID.HEADER_DATA_UNIT,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length " + length + " before LDU1");
}
}
/**
* Test: a place holder with the length of a single missed TDU frame (28 null bits plus the next frame's 112 sync and
* NID bits) is identified as a TDU.
*/
@Test
void testMissedTDU()
{
for(int length : new int[]{128, 136, 140})
{
assertEquals(TDU, forceCompletion(length, TDU, TDU), "Length " + length);
assertEquals(TDU, forceCompletion(length, TDULC, TDU), "Length " + length);
}
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(144, TDU, TDU), "Length 144");
}
/**
* Test: a place holder with the length of a single missed TDULC frame (308 message and null bits plus the next
* frame's 112 sync and NID bits) is identified as a TDULC.
*/
@Test
void testMissedTDULC()
{
for(int length : new int[]{408, 416, 420})
{
assertEquals(TDULC, forceCompletion(length, TDULC, TDULC), "Length " + length);
}
}
/**
* Test: a place holder that doesn't match a missed frame isn't identified as a TDU.
*/
@Test
void testUnidentifiedPlaceHolderDiscarded()
{
for(int length : new int[]{208, 276, 556, 696, 1000})
{
P25P1MessageAssembler assembler = getPlaceHolder(length);
assertEquals(0, assembler.forceCompletion(TDULC, TDU), "Dropped bits at " + length);
assertEquals(P25P1DataUnitID.PLACE_HOLDER, assembler.getDataUnitID(), "Length " + length);
}
//A place holder between voice frames that is too short for an LDU
assertEquals(P25P1DataUnitID.PLACE_HOLDER, forceCompletion(1000, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Length 1000 between LDUs");
}
/**
* Test: place holders are resolved from the next data unit ID before the missed frame lengths are applied, so a
* place holder ahead of a TSBK is a TSBK, even when its length matches a missed TDULC frame.
*/
@Test
void testResolvedFromNextDataUnit()
{
assertEquals(TSBK, forceCompletion(350, TSBK, TSBK), "Missed TSBK");
assertEquals(TSBK, forceCompletion(416, TSBK, TSBK), "Missed TSBK with TDULC length");
assertEquals(P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_2, forceCompletion(1560,
P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1, P25P1DataUnitID.LOGICAL_LINK_DATA_UNIT_1), "Missed LDU2");
}
}

View File

@@ -0,0 +1,80 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the P25 Phase 1 LSM symbol processor.
*/
public class P25P1SymbolProcessorLSMTest
{
private static final float SAMPLES_PER_SYMBOL = 5.0f;
private static final double TOLERANCE = 1e-5;
private static P25P1SymbolProcessorLSM getProcessor()
{
P25P1SymbolProcessorLSM processor = new P25P1SymbolProcessorLSM(new P25P1DecoderLSM(), new P25P1MessageFramer());
processor.setSamplesPerSymbol(SAMPLES_PER_SYMBOL);
return processor;
}
/**
* Converts radians per symbol to the radians per sample domain of the demodulated sample stream.
*/
private static float toSample(double radiansPerSymbol)
{
return (float)(radiansPerSymbol / SAMPLES_PER_SYMBOL);
}
/**
* Test: samples that don't straddle the phase wrap are linearly interpolated.
*/
@Test
void testLinearInterpolation()
{
P25P1SymbolProcessorLSM processor = getProcessor();
assertEquals(toSample(1.5), processor.interpolate(toSample(1.0), toSample(2.0), 0.5), TOLERANCE);
assertEquals(toSample(-1.25), processor.interpolate(toSample(-1.0), toSample(-2.0), 0.25), TOLERANCE);
}
/**
* Test: samples that straddle the phase wrap near an outer symbol interpolate across the wrap instead of through
* zero, and the result is wrapped to +/- PI radians per symbol.
*/
@Test
void testInterpolationAcrossPhaseWrap()
{
P25P1SymbolProcessorLSM processor = getProcessor();
//+3.0 and -3.0 are 0.283 radians apart across the wrap at +/- PI.
assertEquals(toSample(Math.PI), Math.abs(processor.interpolate(toSample(3.0), toSample(-3.0), 0.5)), TOLERANCE);
//A quarter of the way from +3.0 toward -3.0 (across the wrap) is still on the positive side of the wrap.
double expected = 3.0 + 0.25 * (2.0 * Math.PI - 6.0);
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.25), TOLERANCE);
//Three quarters of the way crosses the wrap to the negative side.
expected = 3.0 + 0.75 * (2.0 * Math.PI - 6.0) - 2.0 * Math.PI;
assertEquals(toSample(expected), processor.interpolate(toSample(3.0), toSample(-3.0), 0.75), TOLERANCE);
}
}

View File

@@ -0,0 +1,117 @@
/*
* *****************************************************************************
* 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.p25.phase1.message;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.module.decode.p25.phase1.P25P1DataUnitID;
import io.github.dsheirer.module.decode.p25.phase1.message.hdu.HDUMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.tdu.TDULCMessage;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* JUnit tests for the message validity of P25 Phase 1 messages that decode their error protected content on demand.
* The validity must reflect the error correction result on the first isValid() call, before any other accessor has
* decoded the content.
*/
public class P25P1MessageValidityTest
{
/**
* Creates a message with all-zero bits (a valid codeword for the linear Golay and Reed-Solomon codes).
*/
private static CorrectedBinaryMessage getZeroMessage(P25P1DataUnitID dataUnitID)
{
CorrectedBinaryMessage message = new CorrectedBinaryMessage(dataUnitID.getMessageLength());
message.setSize(dataUnitID.getMessageLength());
return message;
}
/**
* Creates a message with random bits.
*/
private static CorrectedBinaryMessage getRandomMessage(P25P1DataUnitID dataUnitID, long seed)
{
Random random = new Random(seed);
CorrectedBinaryMessage message = getZeroMessage(dataUnitID);
for(int x = 0; x < message.size(); x++)
{
message.set(x, random.nextBoolean());
}
return message;
}
/**
* Test: a TDULC is valid when its link control word passes error correction.
*/
@Test
void testValidTDULC()
{
TDULCMessage tdulc = new TDULCMessage(getZeroMessage(P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL),
0x293, 0L);
assertTrue(tdulc.isValid(), "TDULC");
assertTrue(tdulc.getLinkControlWord().isValid(), "Link control word");
}
/**
* Test: a TDULC is invalid when its link control word fails error correction.
*/
@Test
void testInvalidTDULC()
{
for(long seed = 1; seed <= 10; seed++)
{
TDULCMessage tdulc = new TDULCMessage(getRandomMessage(
P25P1DataUnitID.TERMINATOR_DATA_UNIT_LINK_CONTROL, seed), 0x293, 0L);
assertFalse(tdulc.isValid(), "TDULC with random bits, seed " + seed);
assertFalse(tdulc.getLinkControlWord().isValid(), "Link control word, seed " + seed);
}
}
/**
* Test: an HDU is valid when its header data passes error correction.
*/
@Test
void testValidHDU()
{
HDUMessage hdu = new HDUMessage(getZeroMessage(P25P1DataUnitID.HEADER_DATA_UNIT), 0x293, 0L);
assertTrue(hdu.isValid(), "HDU");
assertTrue(hdu.getHeaderData().isValid(), "Header data");
}
/**
* Test: an HDU is invalid when its header data fails error correction, including when isValid() is invoked before
* the header data is accessed.
*/
@Test
void testInvalidHDU()
{
for(long seed = 1; seed <= 10; seed++)
{
HDUMessage hdu = new HDUMessage(getRandomMessage(P25P1DataUnitID.HEADER_DATA_UNIT, seed), 0x293, 0L);
assertFalse(hdu.isValid(), "HDU with random bits, seed " + seed);
assertFalse(hdu.getHeaderData().isValid(), "Header data, seed " + seed);
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* *****************************************************************************
* 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.source.tuner.frequency;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the channel frequency error manager.
*/
public class ChannelFrequencyErrorManagerTest
{
/**
* Test: a tuner PPM change is removed from the channel's applied frequency correction, and the updated correction
* is applied to the channel source.
*/
@Test
void testTunerPPMChange()
{
TestChannelSource source = new TestChannelSource();
ChannelFrequencyErrorManager manager = new ChannelFrequencyErrorManager(source, null);
manager.receive(-10);
assertEquals(10, manager.getFrequencyError(), "Applied correction");
assertEquals(10, source.mFrequencyCorrection, "Channel source correction");
manager.receive(25);
assertEquals(-15, manager.getFrequencyError(), "Applied correction");
assertEquals(-15, source.mFrequencyCorrection, "Channel source correction");
}
/**
* Channel source that records the applied frequency correction.
*/
private static class TestChannelSource extends TunerChannelSource
{
private long mFrequencyCorrection = Long.MIN_VALUE;
public TestChannelSource()
{
super(null, new TunerChannel(460_000_000L, 12_500), "test", null);
}
@Override
public void setFrequencyCorrection(long correction)
{
mFrequencyCorrection = correction;
}
@Override
public void setFrequency(long frequency)
{
}
@Override
protected void setSampleRate(double sampleRate)
{
}
@Override
public void setListener(Listener<ComplexSamples> complexSamplesListener)
{
}
@Override
public double getSampleRate()
{
return 50_000.0;
}
@Override
public long getFrequency()
{
return 460_000_000L;
}
@Override
public void reset()
{
}
}
}