Files
sdrtrunk/src/main/java/io/github/dsheirer/dsp/c4fm/C4FMEqualizer.java

376 lines
14 KiB
Java

/*
* *****************************************************************************
* 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.c4fm;
import io.github.dsheirer.dsp.symbol.Dibit;
import java.text.DecimalFormat;
import java.util.Arrays;
import org.apache.commons.math3.stat.descriptive.moment.Variance;
/**
* LMS adaptive equalizer for real-valued C4FM symbols. Trains taps on externally triggered sync detection combined
* with dynamic tap updates for a fixed symbol count and then freezes the taps until the error variance between sync
* detections exceeds a fixed threshold, whereby a tap retraining is enqueued for the next sync detection.
*/
public class C4FMEqualizer
{
private static final DecimalFormat DF = new DecimalFormat("0.0000");
//Maximum possible error is PI/4 = 0.78, but we clip it to less than half as the allowable error for tap updates
private static final float MAX_SYMBOL_ERROR = 0.3f;
private static final double TRAINING_IMPROVEMENT_GOAL_PER_ITERATION = 0.0005f;
private static final double TRAINING_CONVERGENCE_THRESHOLD = 0.16f;
private static final double TRAINING_SKIP_VARIANCE_THRESHOLD = 0.05;
private static final int TRAINING_ITERATIONS_MAX = 30;
private final Variance mVariance = new Variance();
private final float[] mBuffer;
private final float[] mReversedSyncSymbols;
private final float[] mTaps;
private final int mCenterTap;
private final int mDelaySymbolCount;
private final int mTrainingPeriodSymbolCount;
private final int mTrainingSkipCountThreshold;
private boolean mEnabled = false;
private boolean mSyncDetected = false;
private int mSymbolsUntilTraining = 0;
private int mTrainingSymbolsRemaining = 0;
private int mSkippedTrainingCount = 0;
/**
* Constructs an instance with an equalizer length derived from the sync pattern length.
*
* @param syncSymbols sync pattern that will be used to train the equalizer on sync detections
* @param trainingPeriodSymbolCount following sync detection to continue training.
* @param maxNoTrainSyncCount maximum number of consecutive sync detections and skipped trainings that can occur without retraining.
*/
public C4FMEqualizer(float[] syncSymbols, int trainingPeriodSymbolCount, int maxNoTrainSyncCount)
{
this(syncSymbols, getDefaultSymbolLength(syncSymbols.length), trainingPeriodSymbolCount, maxNoTrainSyncCount);
}
/**
* Constructs an instance
*
* @param syncSymbols sync pattern that will be used to train the equalizer on sync detections
* @param equalizerSymbolLength equalizer span in symbols. Even values are reduced by one so that the center tap
* has equal delay on either side. The tap count is twice this value since this is a fractional (T/2) equalizer.
* @param trainingPeriodSymbolCount following sync detection to continue training.
* @param maxNoTrainSyncCount maximum number of consecutive sync detections and skipped trainings that can occur without retraining.
*/
public C4FMEqualizer(float[] syncSymbols, int equalizerSymbolLength, int trainingPeriodSymbolCount,
int maxNoTrainSyncCount)
{
if(equalizerSymbolLength < 1)
{
throw new IllegalArgumentException("Equalizer symbol length must be at least 1");
}
//Store the sync pattern in reverse order
mReversedSyncSymbols = new float[syncSymbols.length];
for(int x = 0; x < syncSymbols.length; x++)
{
mReversedSyncSymbols[syncSymbols.length - x - 1] = syncSymbols[x];
}
//Make the symbol count odd length for equal delay on either side of the center tap
int symbolLength = (equalizerSymbolLength % 2 == 0) ? equalizerSymbolLength - 1 : equalizerSymbolLength;
mCenterTap = symbolLength; //This works since we're going to double the buffer length
mDelaySymbolCount = symbolLength / 2; //Delay until new symbol is aligned to center tap
mTaps = new float[symbolLength * 2];
mTaps[mCenterTap] = 1.0f;
int bufferLength = mReversedSyncSymbols.length * 2 + mTaps.length - 2;
mBuffer = new float[bufferLength];
mTrainingPeriodSymbolCount = trainingPeriodSymbolCount;
mTrainingSkipCountThreshold = maxNoTrainSyncCount;
}
/**
* Default equalizer span in symbols derived from the sync pattern length (odd length).
* @param syncLength in symbols
* @return default equalizer symbol length
*/
public static int getDefaultSymbolLength(int syncLength)
{
return (syncLength % 2 == 0) ? syncLength - 1 : syncLength;
}
/**
* Description of the current tap values
*/
public String getTapsDescription()
{
StringBuilder sb = new StringBuilder();
sb.append("<html>EQ Taps<br>");
for(int i = 0; i < mTaps.length; i++)
{
sb.append(i).append(": ").append(DF.format(mTaps[i])).append("<br>");
}
return sb.toString();
}
/**
* Symbol processing delay.
* @return processing delay in symbols.
*/
public int getDelaySymbolCount()
{
return mDelaySymbolCount;
}
/**
* Length of the equalizer in symbols. Note: the internal tap count is twice as long since this
* is a fractional (x2) equalizer.
* @return length in symbols.
*/
public int getLength()
{
return mTaps.length / 2;
}
/**
* Enable the equalizer when a valid sync pattern is detected
*/
public void enable()
{
mEnabled = true;
}
/**
* Indicates the enabled state.
* @return enabled state
*/
public boolean isEnabled()
{
return mEnabled;
}
/**
* Disable the equalizer when the signal is lost so that noise doesn't contaminate the equalizer taps.
*/
public void disable()
{
mEnabled = false;
}
/**
* Notifies the equalizer that a sync pattern is detected and queues a training sequence once a couple more
* symbols arrive once the equalizer delay line has aligned to output the sync pattern.
*/
public void syncDetected()
{
mSyncDetected = true;
mSymbolsUntilTraining = mDelaySymbolCount;
}
/**
* Notifies the equalizer that a sync pattern is detected and updates the sync pattern used for training. This
* supports protocols (ie DMR) that employ multiple sync patterns of the same length.
* @param syncSymbols for the detected sync pattern, which must be the same length as the constructor sync pattern.
*/
public void syncDetected(float[] syncSymbols)
{
if(syncSymbols.length != mReversedSyncSymbols.length)
{
throw new IllegalArgumentException("Sync symbols length [" + syncSymbols.length +
"] must match the equalizer sync pattern length [" + mReversedSyncSymbols.length + "]");
}
for(int x = 0; x < syncSymbols.length; x++)
{
mReversedSyncSymbols[syncSymbols.length - x - 1] = syncSymbols[x];
}
syncDetected();
}
/**
* Indicates that the sync patter is lost and the equalizer should reset.
*/
public void syncLost()
{
// System.out.println("C4FM Equalizer - sync lost triggered");
reset();
disable();
}
/*
* Resets the equalizer taps
*/
public void reset()
{
Arrays.fill(mTaps, 0f);
mTaps[mCenterTap] = 1.0f;
mVariance.clear();
}
/**
* Process the samples and produce a delayed, equalized soft symbol ready for symbol decision. Updates the
* equalizer taps to correct for the error in the equalized sample relative to the hard symbol decision.
*
* @param fractional sample that falls halfway between symbols
* @param symbol to equalize
* @return equalized symbol.
*/
public float process(float fractional, float symbol)
{
//Shift buffer samples to the right two places and add new sample and symbol.
System.arraycopy(mBuffer, 0, mBuffer, 2, mBuffer.length - 2);
mBuffer[0] = fractional;
mBuffer[1] = symbol;
//Since there's a buffer delay, count down from sync detection until the pattern is aligned with the buffer
// contents and we can start training
if(mSyncDetected)
{
if(--mSymbolsUntilTraining <= 0)
{
train();
}
}
float equalized = 0f;
if(mEnabled)
{
int tapIndex;
for(tapIndex = 0; tapIndex < mTaps.length; tapIndex++)
{
equalized += mTaps[tapIndex] * mBuffer[tapIndex];
}
if(mTrainingSymbolsRemaining > 0)
{
mTrainingSymbolsRemaining--;
float error = Dibit.getError(equalized);
error = Math.clamp(error, -MAX_SYMBOL_ERROR, MAX_SYMBOL_ERROR);
//Step size (mu) in range 0.0001 to 0.01
float stepSize = 0.01f;
for(tapIndex = 0; tapIndex < mTaps.length; tapIndex++)
{
if(tapIndex != mCenterTap)
{
mTaps[tapIndex] += stepSize * error * mBuffer[tapIndex];
}
}
}
//Track the residual error that remains after equalization
mVariance.increment(Dibit.getError(equalized));
}
else
{
//Return the uncorrected, delayed symbol when we're disabled
equalized = mBuffer[mCenterTap];
}
return equalized;
}
/**
* Trains the equalizer against the buffered detected sync pattern. Trains against the sync pattern sequence
* multiple times while the per-iteration improvement continues to become smaller and stops training once the
* improvement falls below a threshold goal or exceeds a maximum iterations. If the training converges to
* below a threshold, we snapshot and use the trained taps, otherwise fallback to the previous training snapshot.
*/
private void train()
{
mSyncDetected = false;
//Skip retrain when we're still doing dynamic tap adjustments following the previous training session.
if(mTrainingSymbolsRemaining > 0)
{
mVariance.clear();
return;
}
// System.out.println("**C4FM EQ Training Triggered - skip count[" + mSkippedTrainingCount + "] variance [" + mVariance.getResult() + "] var threshold [" + TRAINING_SKIP_VARIANCE_THRESHOLD + "]");
//Ensure we've collected enough variance samples in the buffer and we have sufficient variance
if(mVariance.getN() <= mDelaySymbolCount || mVariance.getResult() > TRAINING_SKIP_VARIANCE_THRESHOLD ||
++mSkippedTrainingCount > mTrainingSkipCountThreshold)
{
mSkippedTrainingCount = 0;
//If the current variance tracked since the last training session using dynamic tap adjustments at each
// symbol is excessive, fallback to the most recent training snapshot as a better starting point
if(mVariance.getResult() > 0.225)
{
reset();
}
float accumulator, error;
double improvement, previousVariance;
int symbolIndex, index, trainingIterationCount = 0;
Variance variance = new Variance();
//Step size (mu) in range 0.0001 to 0.01
float stepSize = 0.005f;
do
{
previousVariance = variance.getN() == 0 ? 20.0 : variance.getResult();
variance.clear();
for(symbolIndex = 0; symbolIndex < mReversedSyncSymbols.length; symbolIndex++)
{
accumulator = 0.0f;
for(index = 0; index < mTaps.length; index++)
{
accumulator += mTaps[index] * mBuffer[index + (2 * symbolIndex)];
}
error = mReversedSyncSymbols[symbolIndex] - accumulator;
variance.increment(error);
for(index = 0; index < mTaps.length; index++)
{
if(index != mCenterTap)
{
mTaps[index] += error * stepSize * mBuffer[index + (2 * symbolIndex)];
}
}
}
trainingIterationCount++;
improvement = previousVariance - variance.getResult();
}
while(trainingIterationCount < TRAINING_ITERATIONS_MAX && improvement > TRAINING_IMPROVEMENT_GOAL_PER_ITERATION);
// System.out.println("**C4FM EQ - train complete - iterations [" + trainingIterationCount +
// "] end variance [" + DF.format(variance.getResult()) +
// "] final improvement [" + DF.format(improvement) +
// "] improvement threshold [" + DF.format(TRAINING_IMPROVEMENT_GOAL_PER_ITERATION) + "]");
//If we failed to converge, reset the taps to default and use blind training
if(variance.getResult() > TRAINING_CONVERGENCE_THRESHOLD)
{
reset();
}
mTrainingSymbolsRemaining = mTrainingPeriodSymbolCount;
}
mVariance.clear();
}
}