#7 NXDN Decoder support. Redesigns tuner and channel frequency error management. (#2431)

Closes #7 

NXDN Decoder

* Implements: NXDN4800, NXDN9600, Type-C and Type-D decoding and trunk tracking.
* Redesigns tuner and channel frequency error management.
This commit is contained in:
Denny Sheirer
2026-07-08 03:44:08 -04:00
committed by GitHub
parent a053315675
commit 7945f0ce63
445 changed files with 30854 additions and 2053 deletions

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* 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
@@ -607,6 +607,22 @@ public class FilterFactory
return cicArray;
}
/**
* Creates a Root Raised Cosine filter.
* @param samplesPerSymbol
* @param symbolCount
* @param alpha
* @return
*/
public static float[] getRRC(double samplesPerSymbol, int symbolCount, float alpha)
{
//TODO: this wraps the existing method which is reducing the samples per symbol value x2 internally and is likely
// a mistake. This method is used by NXDN to correct that error until the DMR and P25 decoders can be updated to
// ensure that the correction doesn't impact those decoders. Once the cleanup is complete, this method can be
// deleted and the NXDN implementation can revert to using the future corrected version.
return getRootRaisedCosine(samplesPerSymbol * 2, symbolCount, alpha);
}
/**
* Creates root raised cosine filter coefficients with a tap count equal to the symbols x samplesPerSymbol + 1.
*
@@ -963,11 +979,11 @@ public class FilterFactory
* @return filter coefficients.
* @throws FilterDesignException if the requested length is not odd
*/
public static float[] getSinc(double cutoff, int length, WindowType windowType) throws FilterDesignException
public static float[] getSinc(double cutoff, int length, WindowType windowType)
{
if(length % 2 == 0)
{
throw new FilterDesignException("Sinc filters must be odd-length");
throw new IllegalArgumentException("Sinc filters must be odd-length");
}
float[] coefficients = new float[length];
@@ -983,7 +999,46 @@ public class FilterFactory
for(int x = 1; x <= half; x++)
{
double a = piScalor * x;
double coefficient = scalor * FastMath.sin(a) / a;
double coefficient = scalor * (FastMath.sin(a) / a);
coefficient *= window[half + x];
coefficients[half + x] = (float)coefficient;
coefficients[half - x] = (float)coefficient;
}
return coefficients;
}
/**
* Creates a windowed-sync (Nyquist) filter.
*
* @param cutoff frequency (0.0 <> 0.5)
* @param length of the filter - must be odd-length
* @param windowType to use in designing the filter
* @return filter coefficients.
* @throws FilterDesignException if the requested length is not odd
*/
public static float[] getInverseSync(double cutoff, int length, WindowType windowType)
{
if(length % 2 == 0)
{
throw new IllegalArgumentException("Sinc filters must be odd-length");
}
float[] coefficients = new float[length];
int half = length / 2;
float[] window = WindowFactory.getWindow(windowType, length);
double scalor = 2.0 * cutoff;
double piScalor = FastMath.PI * scalor;
coefficients[half] = (float)(1.0 * scalor * window[half]);
for(int x = 1; x <= half; x++)
{
double a = piScalor * x;
double coefficient = scalor * (a / FastMath.sin(a));
coefficient *= window[half + x];
coefficients[half + x] = (float)coefficient;

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* 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

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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
@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.filter.channelizer;
import io.github.dsheirer.buffer.INativeBuffer;
import io.github.dsheirer.buffer.INativeBufferProvider;
import io.github.dsheirer.controller.channel.event.ChannelStopProcessingRequest;
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
import io.github.dsheirer.eventbus.MyEventBus;
@@ -73,7 +72,7 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
private static final int POLYPHASE_CHANNELIZER_TAPS_PER_CHANNEL = 9;
private Broadcaster<SourceEvent> mSourceEventBroadcaster = new Broadcaster<>();
private INativeBufferProvider mNativeBufferProvider;
private TunerController mTunerController;
private List<PolyphaseChannelSource> mChannelSources = new CopyOnWriteArrayList<>();
private ChannelCalculator mChannelCalculator;
private SynthesisFilterManager mFilterManager = new SynthesisFilterManager();
@@ -85,23 +84,20 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
private boolean mRunning = true;
/**
* Creates a polyphase channel manager instance.
* Creates a polyphase channel manager for the tuner controller
*
* @param nativeBufferProvider (ie tuner) that supports register/deregister for reusable baseband sample buffer
* streams
* @param frequency of the baseband complex buffer sample stream (ie center frequency)
* @param sampleRate of the baseband complex buffer sample stream
* @param tunerController for a tuner that provides a baseband complex buffer stream.
*/
public PolyphaseChannelManager(INativeBufferProvider nativeBufferProvider, long frequency, double sampleRate)
public PolyphaseChannelManager(TunerController tunerController)
{
if(nativeBufferProvider == null)
if(tunerController == null)
{
throw new IllegalArgumentException("Complex buffer provider argument cannot be null");
}
mNativeBufferProvider = nativeBufferProvider;
mTunerController = tunerController;
int channelCount = (int)(sampleRate / MINIMUM_CHANNEL_BANDWIDTH);
int channelCount = (int)(tunerController.getSampleRate() / MINIMUM_CHANNEL_BANDWIDTH);
//Ensure channel count is an even integer since we're using a 2x oversampling polyphase channelizer
if(channelCount % 2 != 0)
@@ -109,21 +105,12 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
channelCount--;
}
mChannelCalculator = new ChannelCalculator(sampleRate, channelCount, frequency, CHANNEL_OVERSAMPLING);
mChannelCalculator = new ChannelCalculator(tunerController.getSampleRate(), channelCount,
tunerController.getFrequency(), CHANNEL_OVERSAMPLING);
mBufferDispatcher = new Dispatcher("sdrtrunk polyphase buffer processor", 10);
mBufferDispatcher.setListener(mNativeBufferReceiver);
}
/**
* Creates a polyphase channel manager for the tuner controller
*
* @param tunerController for a tuner that provides a baseband complex buffer stream.
*/
public PolyphaseChannelManager(TunerController tunerController)
{
this(tunerController, tunerController.getFrequency(), tunerController.getSampleRate());
}
/**
* Provides a description of the state of this manager.
*/
@@ -201,7 +188,7 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
try
{
channelSource = new PolyphaseChannelSource(tunerChannel, mChannelCalculator, mFilterManager,
mChannelSourceEventListener, threadName);
mChannelSourceEventListener, threadName, mTunerController.getTunerFrequencyErrorManager());
mChannelSources.add(channelSource);
}
@@ -234,7 +221,7 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
//If this is the first channel, register to start the sample buffers flowing
if(mPolyphaseChannelizer.getRegisteredChannelCount() == 1)
{
mNativeBufferProvider.addBufferListener(mBufferDispatcher);
mTunerController.addBufferListener(mBufferDispatcher);
mPolyphaseChannelizer.start();
mBufferDispatcher.start();
}
@@ -263,7 +250,7 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
//If this is the last/only channel, deregister to stop the sample buffers
if(mPolyphaseChannelizer != null && mPolyphaseChannelizer.getRegisteredChannelCount() == 0)
{
mNativeBufferProvider.removeBufferListener(mBufferDispatcher);
mTunerController.removeBufferListener(mBufferDispatcher);
mBufferDispatcher.stop();
mPolyphaseChannelizer.stop();
}
@@ -303,6 +290,7 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
case NOTIFICATION_FREQUENCY_AND_SAMPLE_RATE_LOCKED:
case NOTIFICATION_FREQUENCY_AND_SAMPLE_RATE_UNLOCKED:
case NOTIFICATION_RECORDING_FILE_LOADED:
case NOTIFICATION_MEASURED_FREQUENCY_ERROR:
//no-op
break;
case NOTIFICATION_FREQUENCY_CORRECTION_CHANGE:
@@ -456,9 +444,9 @@ public class PolyphaseChannelManager implements ISourceEventProcessor
(sourceEvent.hasSource() ? sourceEvent.getSource().getClass() : "null source"));
}
break;
case NOTIFICATION_MEASURED_FREQUENCY_ERROR_SYNC_LOCKED:
//Rebroadcast so that the tuner source can process this event
mSourceEventBroadcaster.broadcast(sourceEvent);
case REQUEST_FREQUENCY_CORRECTION:
case NOTIFICATION_MEASURED_FREQUENCY_ERROR:
//Ignore
break;
default:
mLog.error("Received unrecognized source event from polyphase channel source [" +

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* 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
@@ -27,6 +27,7 @@ import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import io.github.dsheirer.source.tuner.frequency.TunerFrequencyErrorManager;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
@@ -47,6 +48,8 @@ public class PolyphaseChannelSource extends TunerChannelSource implements Listen
private List<Integer> mOutputProcessorIndexes = new ArrayList<>();
private double mTunerSampleRate;
private double mTunerCenterFrequency;
private long mFrequencyCorrection;
private boolean mPendingFrequencyCorrectionUpdate = false;
private PendingOutputProcessorUpdate mPendingOutputProcessorUpdate;
/**
@@ -57,17 +60,27 @@ public class PolyphaseChannelSource extends TunerChannelSource implements Listen
* @param filterManager for access to new or cached synthesis filters
* @param producerSourceEventListener to receive source event requests (e.g. start/stop sample stream)
* @param threadName for the channel's dispatcher
* @param tunerFrequencyErrorManager to connect with the channel frequency error manager
* @throws IllegalArgumentException if a channel low pass filter can't be designed to the channel specification
*/
public PolyphaseChannelSource(TunerChannel tunerChannel, ChannelCalculator channelCalculator, SynthesisFilterManager filterManager,
Listener<SourceEvent> producerSourceEventListener, String threadName)
public PolyphaseChannelSource(TunerChannel tunerChannel, ChannelCalculator channelCalculator,
SynthesisFilterManager filterManager,
Listener<SourceEvent> producerSourceEventListener, String threadName,
TunerFrequencyErrorManager tunerFrequencyErrorManager)
throws IllegalArgumentException
{
super(producerSourceEventListener, tunerChannel, threadName);
super(producerSourceEventListener, tunerChannel, threadName, tunerFrequencyErrorManager);
mChannelSampleRate = channelCalculator.getChannelSampleRate();
doUpdateOutputProcessor(channelCalculator, filterManager);
}
@Override
public void setFrequencyCorrection(long correction)
{
mFrequencyCorrection = correction;
mPendingFrequencyCorrectionUpdate = true;
}
/**
* Description of the state or configuration of the output processor.
*/
@@ -280,6 +293,13 @@ public class PolyphaseChannelSource extends TunerChannelSource implements Listen
doUpdateOutputProcessor(channelCalculator, filterManager);
}
//Apply channel decider requested frequency correction
if(mPendingFrequencyCorrectionUpdate)
{
mPolyphaseChannelOutputProcessor.setFrequencyOffset(getFrequencyOffset());
mPendingFrequencyCorrectionUpdate = false;
}
try
{
if(mPolyphaseChannelOutputProcessor != null)
@@ -349,7 +369,7 @@ public class PolyphaseChannelSource extends TunerChannelSource implements Listen
*/
public long getFrequencyOffset()
{
return mIndexCenterFrequency - getTunerChannel().getFrequency();
return mIndexCenterFrequency - getTunerChannel().getFrequency() + mFrequencyCorrection;
}
@Override

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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
@@ -52,6 +52,44 @@ public enum Dibit
mPhase = phase;
}
private static final float SOFT_SYMBOL_QUADRANT_BOUNDARY = (float)(Math.PI / 2.0);
/**
* Decodes the sample value to determine the correct QPSK quadrant and maps the value to a Dibit symbol.
* @param sample in radians.
* @return symbol decision.
*/
public static Dibit fromSample(float sample)
{
if(sample > 0)
{
return sample > SOFT_SYMBOL_QUADRANT_BOUNDARY ? Dibit.D01_PLUS_3 : Dibit.D00_PLUS_1;
}
else
{
return sample < -SOFT_SYMBOL_QUADRANT_BOUNDARY ? Dibit.D11_MINUS_3 : Dibit.D10_MINUS_1;
}
}
/**
* Calculates the error from the sample by first mapping it to a symbol and the error is the difference from
* the ideal phase of that symbol.
* @param sample to process
* @return error from ideal
*/
public static float getError(float sample)
{
if(sample > 0)
{
return sample > SOFT_SYMBOL_QUADRANT_BOUNDARY ? Dibit.D01_PLUS_3.mPhase - sample : Dibit.D00_PLUS_1.mPhase - sample;
}
else
{
return sample < -SOFT_SYMBOL_QUADRANT_BOUNDARY ? Dibit.D11_MINUS_3.mPhase - sample : Dibit.D10_MINUS_1.mPhase - sample;
}
}
/**
* Binary value of the symbol
*/

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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
@@ -73,6 +73,18 @@ public class DibitDelayLine
}
}
/**
* Inserts the dibit array into the buffer
* @param dibits to fill
*/
public void fill(Dibit[] dibits)
{
for(Dibit dibit: dibits)
{
insert(dibit);
}
}
/**
* Adjusts the pointer to correct a dibit stuff/delete.
* @param offset to adjust.
@@ -110,7 +122,7 @@ public class DibitDelayLine
public void log()
{
StringBuilder sb = new StringBuilder();
sb.append("Pointer [").append(mPointer).append("] ");
int pointer = mPointer;
for(int i = 0; i < mDelayLine.length; i++)
{

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 Dennis Sheirer
* 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
@@ -20,6 +20,7 @@ package io.github.dsheirer.dsp.symbol;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.buffer.IByteBufferProvider;
import java.nio.BufferOverflowException;
import java.nio.ByteBuffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,8 +30,7 @@ import org.slf4j.LoggerFactory;
*/
public class DibitToByteBufferAssembler implements Listener<Dibit>, IByteBufferProvider
{
private final static Logger mLog = LoggerFactory.getLogger(DibitToByteBufferAssembler.class);
private final static Logger LOGGER = LoggerFactory.getLogger(DibitToByteBufferAssembler.class);
private ByteBuffer mCurrentBuffer;
private int mBufferSize;
private byte mCurrentByte;
@@ -53,14 +53,21 @@ public class DibitToByteBufferAssembler implements Listener<Dibit>, IByteBufferP
* the buffer pointer to zero so that new dibits can be loaded.
*/
private void getNextBuffer()
{
closeAndBroadcast();
mCurrentBuffer = ByteBuffer.allocate(mBufferSize);
}
/**
* Closes the current buffer and broadcasts it to an optionally registered listener
*/
private void closeAndBroadcast()
{
if(mCurrentBuffer != null && mBufferListener != null)
{
mCurrentBuffer.flip();
mBufferListener.receive(mCurrentBuffer);
}
mCurrentBuffer = ByteBuffer.allocate(mBufferSize);
}
@Override
@@ -88,17 +95,33 @@ public class DibitToByteBufferAssembler implements Listener<Dibit>, IByteBufferP
if(mDibitCount >= 4)
{
mCurrentBuffer.put(mCurrentByte);
mCurrentByte = 0x00;
mDibitCount = 0;
if(!mCurrentBuffer.hasRemaining())
{
getNextBuffer();
}
try
{
mCurrentBuffer.put(mCurrentByte);
}
catch (BufferOverflowException e)
{
//This shouldn't happen.
LOGGER.info("Detected buffer overflow in dibit buffer assembler - continuing");
}
mCurrentByte = 0x00;
mDibitCount = 0;
}
}
/**
* Flush any buffered bits to the registered listener.
*/
public void flush()
{
closeAndBroadcast();
}
/**
* Registers the listener to receive fully assembled byte buffers from this assembler.
*/