mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
Changes to model, view and controller components and updates all gui editors. Removes the configuration panel and tree and replaces with 3 new panels. Updates playlist.xml to a new playlist_v2.xml format.
590 lines
16 KiB
Java
590 lines
16 KiB
Java
package dsp.psk;
|
|
|
|
/*******************************************************************************
|
|
* SDR Trunk
|
|
* Copyright (C) 2014, 2015 Dennis Sheirer
|
|
*
|
|
* Gardner detector, Costas loop and Interpolator derived from:
|
|
*
|
|
* OP25 - gardner_costas_cc_impl.cc
|
|
* Copyright 2010, 2011, 2012, 2013 KA1RBI (gardner_costas_cc_impl.cc)
|
|
*
|
|
* OP25 - scope.py
|
|
* Copyright 2008-2011 Steve Glass
|
|
* Copyright 2011, 2012, 2013 KA1RBI
|
|
*
|
|
* GNURadio - control_loop and mpsk_receiver_cc
|
|
* Copyright 2011,2013 Free Software Foundation, Inc.
|
|
*
|
|
* 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/>
|
|
******************************************************************************/
|
|
|
|
import instrument.Instrumentable;
|
|
import instrument.tap.Tap;
|
|
import instrument.tap.TapGroup;
|
|
import instrument.tap.stream.EyeDiagramData;
|
|
import instrument.tap.stream.EyeDiagramDataTap;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.List;
|
|
|
|
import sample.Listener;
|
|
import sample.complex.Complex;
|
|
import sample.complex.ComplexSampleListener;
|
|
import source.tuner.frequency.IFrequencyChangeListener;
|
|
import buffer.FloatAveragingBuffer;
|
|
import dsp.filter.interpolator.RealInterpolator;
|
|
import dsp.symbol.Dibit;
|
|
|
|
/**
|
|
* Implements a LSM (Pi/4) demodulator using a Gardner Detector to determine
|
|
* optimal sampling timing and a Costas Loop as a phase locked loop synchronized
|
|
* with the incoming signal carrier frequency.
|
|
*
|
|
* Sample Rate: 48000
|
|
* Symbol Rate: 4800
|
|
*/
|
|
public class LSMDemodulator implements Instrumentable, ComplexSampleListener
|
|
{
|
|
private List<TapGroup> mAvailableTaps;
|
|
|
|
/* 45 degree rotation angle */
|
|
public static final float THETA = (float)( Math.PI / 4.0d );
|
|
|
|
/* 45 degree point */
|
|
public static final Complex POINT_45_DEGREES =
|
|
new Complex( (float)Math.sin( THETA ), (float)Math.cos( THETA ) );
|
|
|
|
private Listener<Complex> mSymbolListener;
|
|
|
|
private GardnerSymbolTiming mGardnerDetector = new GardnerSymbolTiming();
|
|
|
|
private CostasLoop mCostasLoop = new CostasLoop();
|
|
|
|
private IFrequencyChangeListener mFrequencyChangeListener;
|
|
|
|
private EyeDiagramDataTap mEyeDiagramDataTap;
|
|
|
|
public LSMDemodulator()
|
|
{
|
|
}
|
|
|
|
public void dispose()
|
|
{
|
|
mSymbolListener = null;
|
|
mFrequencyChangeListener = null;
|
|
}
|
|
|
|
public void setSymbolListener( Listener<Complex> listener )
|
|
{
|
|
mSymbolListener = listener;
|
|
}
|
|
|
|
public void removeSymbolListener()
|
|
{
|
|
mSymbolListener = null;
|
|
}
|
|
|
|
@Override
|
|
public void receive( float inphase, float quadrature )
|
|
{
|
|
mGardnerDetector.receive( inphase, quadrature );
|
|
}
|
|
|
|
/**
|
|
* Applies a phase correction value to the costas loop to correct when a
|
|
* phase lock error is detected in the binary output stream.
|
|
*
|
|
* @param correction - value in radians
|
|
*/
|
|
public void correctPhaseError( double correction )
|
|
{
|
|
mCostasLoop.correctPhaseError( correction );
|
|
}
|
|
|
|
/**
|
|
* Constrains value to the range of ( -maximum <> maximum )
|
|
*/
|
|
public static float clip( float value, float maximum )
|
|
{
|
|
if( value > maximum )
|
|
{
|
|
return maximum;
|
|
}
|
|
else if( value < -maximum )
|
|
{
|
|
return -maximum;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Constrains timing error to +/- the maximum value and corrects any
|
|
* floating point invalid numbers
|
|
*/
|
|
private float normalize( float error, float maximum )
|
|
{
|
|
if( Float.isNaN( error ) )
|
|
{
|
|
return 0.0f;
|
|
}
|
|
else
|
|
{
|
|
return clip( error, maximum );
|
|
}
|
|
}
|
|
|
|
public void addListener( IFrequencyChangeListener listener )
|
|
{
|
|
mFrequencyChangeListener = listener;
|
|
}
|
|
|
|
public void removeListener( IFrequencyChangeListener listener )
|
|
{
|
|
mFrequencyChangeListener = null;
|
|
}
|
|
|
|
/**
|
|
* Processes the current loop frequency of the costas loop and broadcasts
|
|
* tuner frequency offset adjustments 10 times a second as needed to keep
|
|
* the frequency offset below half of the maximum frequency
|
|
*/
|
|
public class FrequencyControl
|
|
{
|
|
/* Set the trigger threshold at half of the maximum costas loop control
|
|
* frequency */
|
|
private static final float CORRECTION_THRESHOLD_FREQUENCY =
|
|
( 2.0f * (float)Math.PI * 600.0f ) / 48000.0f;
|
|
|
|
private FloatAveragingBuffer mBuffer = new FloatAveragingBuffer( 40 );
|
|
|
|
private long mFrequencyError = 0;
|
|
|
|
private int mCounter = 0;
|
|
|
|
public void receive( float frequency )
|
|
{
|
|
// mCounter++;
|
|
//
|
|
// if( mCounter >= 480 )
|
|
// {
|
|
// float average = mBuffer.get( frequency );
|
|
// mLog.debug( "Avg:" + average + " Threshold:" + CORRECTION_THRESHOLD_FREQUENCY);
|
|
//
|
|
// boolean correctionNeeded = false;
|
|
//
|
|
// if( average > CORRECTION_THRESHOLD_FREQUENCY )
|
|
// {
|
|
// mFrequencyError--;
|
|
//
|
|
// correctionNeeded = true;
|
|
// }
|
|
// else if( average < -CORRECTION_THRESHOLD_FREQUENCY )
|
|
// {
|
|
// mFrequencyError++;
|
|
//
|
|
// correctionNeeded = true;
|
|
// }
|
|
//
|
|
// if( correctionNeeded && mFrequencyChangeListener != null )
|
|
// {
|
|
// mLog.debug( "Issuing Frequency Correction: " + mFrequencyError );
|
|
// mFrequencyChangeListener.frequencyChanged(
|
|
// new FrequencyChangeEvent( Attribute.FREQUENCY_ERROR,
|
|
// mFrequencyError ) );
|
|
// }
|
|
//
|
|
// mCounter = 0;
|
|
// }
|
|
// else
|
|
// {
|
|
// mBuffer.get( frequency );
|
|
// }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gardner symbol timing detector
|
|
*/
|
|
public class GardnerSymbolTiming implements ComplexSampleListener
|
|
{
|
|
public static final int HALF_SAMPLES_PER_SYMBOL = 5;
|
|
public static final int SAMPLES_PER_SYMBOL = 10;
|
|
public static final int TWICE_SAMPLES_PER_SYMBOL = 20;
|
|
|
|
private float[] mDelayLineInphase = new float[ 2 * TWICE_SAMPLES_PER_SYMBOL ];
|
|
private float[] mDelayLineQuadrature = new float[ 2 * TWICE_SAMPLES_PER_SYMBOL ];
|
|
|
|
private int mDelayLinePointer = 0;
|
|
|
|
/* Sampling point */
|
|
private float mMu = 10.0f;
|
|
private float mGainMu = 0.05f;
|
|
|
|
/* Samples per symbol */
|
|
private float mOmega = 10.0f;
|
|
private float mGainOmega = 0.1f * mGainMu * mGainMu;
|
|
private float mOmegaRel = 0.005f;
|
|
private float mOmegaMid = 10.0f;
|
|
|
|
private RealInterpolator mInterpolator = new RealInterpolator( 1.0f );
|
|
|
|
private Complex mPreviousSample = new Complex( 0, 0 );
|
|
private Complex mPreviousMiddleSample = new Complex( 0, 0 );
|
|
private Complex mPreviousSymbol = new Complex( 0, 0 );
|
|
|
|
/**
|
|
* Provides symbol sampling timing control
|
|
*/
|
|
public GardnerSymbolTiming()
|
|
{
|
|
}
|
|
|
|
@Override
|
|
public void receive( float inphase, float quadrature )
|
|
{
|
|
/* Count down samples per symbol until we calculate the symbol */
|
|
mMu--;
|
|
|
|
mCostasLoop.increment();
|
|
|
|
/* Mix incoming sample with costas loop to remove any rotation
|
|
* that is present from a mis-tuned carrier frequency */
|
|
|
|
Complex costas = mCostasLoop.getCurrentVector();
|
|
|
|
float derotatedInphase = Complex.multiplyInphase( inphase, quadrature, costas.inphase(), costas.quadrature() );
|
|
float derotatedQuadrature = Complex.multiplyQuadrature( inphase, quadrature, costas.inphase(), costas.quadrature() );
|
|
|
|
/* Fill up the delay line to use with the interpolator */
|
|
mDelayLineInphase[ mDelayLinePointer ] = derotatedInphase;
|
|
mDelayLineInphase[ mDelayLinePointer + TWICE_SAMPLES_PER_SYMBOL ] = derotatedInphase;
|
|
|
|
mDelayLineQuadrature[ mDelayLinePointer ] = derotatedQuadrature;
|
|
mDelayLineQuadrature[ mDelayLinePointer + TWICE_SAMPLES_PER_SYMBOL ] = derotatedQuadrature;
|
|
|
|
/* Increment pointer and keep pointer in bounds */
|
|
mDelayLinePointer = ( mDelayLinePointer + 1 ) % TWICE_SAMPLES_PER_SYMBOL;
|
|
|
|
/* Calculate the symbol once we've stored enough samples */
|
|
if( mMu <= 1.0f )
|
|
{
|
|
float half_omega = mOmega / 2.0f;
|
|
int half_sps = (int)Math.floor( half_omega );
|
|
float half_mu = mMu + half_omega - (float)half_sps;
|
|
|
|
if( half_mu > 1.0 )
|
|
{
|
|
half_mu -= 1.0;
|
|
half_sps += 1;
|
|
}
|
|
|
|
/* Calculate interpolated middle sample and current sample */
|
|
float middleSampleInphase = mInterpolator.filter(
|
|
mDelayLineInphase, mDelayLinePointer, mMu );
|
|
float middleSampleQuadrature = mInterpolator.filter(
|
|
mDelayLineQuadrature, mDelayLinePointer, mMu );
|
|
|
|
int index = mDelayLinePointer + half_sps;
|
|
|
|
float currentSampleInphase = mInterpolator.filter(
|
|
mDelayLineInphase, index, half_mu );
|
|
float currentSampleQuadrature = mInterpolator.filter(
|
|
mDelayLineQuadrature, index, half_mu );
|
|
|
|
/* Multiply current sample and conjugate (negated quadrature) of
|
|
* previous sample to get symbols to use for gardner error feedback */
|
|
Complex middleSymbol = Complex.multiply( middleSampleInphase,
|
|
middleSampleQuadrature, mPreviousMiddleSample.conjugate() );
|
|
Complex currentSymbol = Complex.multiply( currentSampleInphase,
|
|
currentSampleQuadrature, mPreviousSample.conjugate() );
|
|
|
|
/* Set gain to unity */
|
|
middleSymbol.normalize();
|
|
currentSymbol.normalize();
|
|
|
|
/* Gardner timing error calculations */
|
|
float errorInphase = ( mPreviousSymbol.inphase() -
|
|
currentSymbol.inphase() ) * middleSymbol.inphase();
|
|
|
|
float errorQuadrature = ( mPreviousSymbol.quadrature() -
|
|
currentSymbol.quadrature() ) * middleSymbol.quadrature();
|
|
|
|
float gardnerError = normalize( errorInphase + errorQuadrature, 1.0f );
|
|
|
|
if( mEyeDiagramDataTap != null )
|
|
{
|
|
mEyeDiagramDataTap.receive(
|
|
new EyeDiagramData(
|
|
Arrays.copyOfRange( mDelayLineInphase, 0, 20 ),
|
|
Arrays.copyOfRange( mDelayLineQuadrature, 0, 20 ),
|
|
mMu, (float)half_sps + half_mu, gardnerError ) );
|
|
}
|
|
|
|
/* mOmega is samples per symbol and is constrained to floating
|
|
* between +/- .005 of the nominal 10.0 samples per symbol */
|
|
mOmega = mOmega + mGainOmega * gardnerError;
|
|
mOmega = mOmegaMid + clip( mOmega - mOmegaMid, mOmegaRel );
|
|
|
|
/* Adjust sample timing based on error of current sample */
|
|
mMu += mOmega + ( mGainMu * gardnerError );
|
|
|
|
/* Store current samples/symbols to use for the next period */
|
|
mPreviousSample.setValues( currentSampleInphase, currentSampleQuadrature );
|
|
mPreviousMiddleSample.setValues( middleSampleInphase, middleSampleQuadrature );
|
|
mPreviousSymbol = currentSymbol;
|
|
|
|
/* Update costas loop using phase error present in current
|
|
* symbol. The symbol is rotated from star orientation to polar
|
|
* orientation to simplify error calculation */
|
|
mCostasLoop.receive( currentSymbol );
|
|
// mCostasLoop.receive(
|
|
// ComplexSample.multiply( currentSymbol, POINT_45_DEGREES ) );
|
|
|
|
/* Dispatch the differentiated symbol to the registered listener */
|
|
if( mSymbolListener != null )
|
|
{
|
|
mSymbolListener.receive( currentSymbol );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Costas Loop - phase locked loop synchronized to the frequency offset of
|
|
* the incoming signal to enable the signal to be mixed down to zero offset
|
|
* for proper synchronization. The mFrequency value indicates the detected
|
|
* frequency offset. We attempt to keep that value close to zero by issuing
|
|
* frequency adjustments to the tuner channel source.
|
|
*
|
|
* Most of the costas loop code was ported from gnuradio/control_loop and
|
|
* gnuradio/mpsk_receiver_cc using initialization values from KA1RBI's
|
|
* OP25/cqpsk.py
|
|
*/
|
|
public class CostasLoop implements Listener<Complex>
|
|
{
|
|
public static final double TWO_PI = 2.0 * Math.PI;
|
|
|
|
private static final float MAXIMUM_FREQUENCY =
|
|
( 2400.0f * (float)TWO_PI ) / 48000.0f;
|
|
|
|
/* http://www.trondeau.com/blog/2011/8/13/control-loop-gain-values.html */
|
|
private float mDamping = (float)Math.sqrt( 2.0 ) / 2.0f;
|
|
|
|
/* Use denominator between 100 (most) and 400 (least) to adjust costas
|
|
* loop control level */
|
|
private float mLoopBandwidth = (float)( TWO_PI / 400.0d );
|
|
|
|
private float mAlphaGain = ( 4.0f * mDamping * mLoopBandwidth ) /
|
|
( 1.0f + ( 2.0f * mDamping * mLoopBandwidth ) +
|
|
( mLoopBandwidth * mLoopBandwidth ) );
|
|
|
|
private float mBetaGain = ( 4.0f * mLoopBandwidth * mLoopBandwidth ) /
|
|
( 1.0f + ( 2.0f * mDamping * mLoopBandwidth ) +
|
|
( mLoopBandwidth * mLoopBandwidth ) );
|
|
|
|
private float mLoopPhase = 0.0f;
|
|
|
|
private float mLoopFrequency = 0.0f;
|
|
|
|
private FrequencyControl mFrequencyControl = new FrequencyControl();
|
|
|
|
public CostasLoop()
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Applies a phase correction value to the current phasor. Use this
|
|
* method to apply correction when a +/- 90, or 180 degree phase error
|
|
* is detected in the binary output stream.
|
|
*
|
|
* If the supplied correction value places the loop frequency outside
|
|
* of the max frequency, then the frequency will be corrected 360
|
|
* degrees in the opposite direction to maintain within the max
|
|
* frequency bounds.
|
|
*
|
|
* @param correction - correction value in radians
|
|
*/
|
|
public void correctPhaseError( double correction )
|
|
{
|
|
mLoopFrequency += correction;
|
|
|
|
if( mLoopFrequency > MAXIMUM_FREQUENCY )
|
|
{
|
|
mLoopFrequency -= 2.0d * MAXIMUM_FREQUENCY;
|
|
}
|
|
|
|
if( mLoopFrequency < -MAXIMUM_FREQUENCY )
|
|
{
|
|
mLoopFrequency += 2.0d * MAXIMUM_FREQUENCY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increments the phase of the loop
|
|
*/
|
|
public void increment()
|
|
{
|
|
mLoopPhase += mLoopFrequency;
|
|
|
|
/* Keep the loop phase in bounds */
|
|
unwrapPhase();
|
|
}
|
|
|
|
private void unwrapPhase()
|
|
{
|
|
while( mLoopPhase > TWO_PI )
|
|
{
|
|
mLoopPhase -= TWO_PI;
|
|
}
|
|
|
|
while( mLoopPhase < -TWO_PI )
|
|
{
|
|
mLoopPhase += TWO_PI;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Current vector of the loop rotated by THETA degrees.
|
|
*/
|
|
public Complex getCurrentVector()
|
|
{
|
|
// return ComplexSample.fromAngle( mLoopPhase + THETA );
|
|
return Complex.fromAngle( mLoopPhase );
|
|
}
|
|
|
|
@Override
|
|
public void receive( Complex complex )
|
|
{
|
|
/* Calculate phase error */
|
|
float phaseError = getPhaseError( complex );
|
|
|
|
Dibit d = QPSKPolarSlicer.decide( complex );
|
|
// mLog.debug( "Dibit: " + d.name() + " Error: " + phaseError );
|
|
|
|
/* Adjust for phase error */
|
|
adjust( phaseError );
|
|
}
|
|
|
|
/**
|
|
* Updates the costas loop frequency and phase to adjust for the phase
|
|
* error value
|
|
*
|
|
* @param phase_error - (-)= late and (+)= early
|
|
*/
|
|
private void adjust( float phase_error )
|
|
{
|
|
mLoopFrequency += mBetaGain * phase_error;
|
|
mLoopPhase += mLoopFrequency + mAlphaGain * phase_error;
|
|
|
|
/* Maintain phase between +/- 2 * PI */
|
|
unwrapPhase();
|
|
|
|
/* Limit frequency to +/- maximum loop frequency */
|
|
limitFrequency();
|
|
}
|
|
|
|
/**
|
|
* Constrains the frequency within the bounds of +/- loop frequency
|
|
*/
|
|
private void limitFrequency()
|
|
{
|
|
/* Check for and issue tuner offset correction */
|
|
mFrequencyControl.receive( mLoopFrequency );
|
|
|
|
if( mLoopFrequency > MAXIMUM_FREQUENCY )
|
|
{
|
|
mLoopFrequency = MAXIMUM_FREQUENCY;
|
|
}
|
|
|
|
if( mLoopFrequency < -MAXIMUM_FREQUENCY )
|
|
{
|
|
mLoopFrequency = -MAXIMUM_FREQUENCY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects rotational error present when the costas loop is not aligned
|
|
* with the carrier frequency. Provides error feedback to adjust
|
|
* mixer frequency.
|
|
*/
|
|
public float getPhaseError( Complex complex )
|
|
{
|
|
float phase_error = 0;
|
|
|
|
if( Math.abs( complex.inphase() ) > Math.abs( complex.quadrature() ) )
|
|
{
|
|
if( complex.inphase() > 0 )
|
|
{
|
|
phase_error = -complex.quadrature();
|
|
}
|
|
else
|
|
{
|
|
phase_error = complex.quadrature();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if( complex.quadrature() > 0 )
|
|
{
|
|
phase_error = complex.inphase();
|
|
}
|
|
else
|
|
{
|
|
phase_error = -complex.inphase();
|
|
}
|
|
}
|
|
|
|
return phase_error;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public List<TapGroup> getTapGroups()
|
|
{
|
|
if( mAvailableTaps == null )
|
|
{
|
|
mAvailableTaps = new ArrayList<>();
|
|
|
|
TapGroup group = new TapGroup( "LSM Demodulator" );
|
|
|
|
group.add( new EyeDiagramDataTap( "Eye Diagram", 0, 4800 ) );
|
|
|
|
mAvailableTaps.add( group );
|
|
}
|
|
|
|
return mAvailableTaps;
|
|
}
|
|
|
|
@Override
|
|
public void registerTap( Tap tap )
|
|
{
|
|
if( tap instanceof EyeDiagramDataTap )
|
|
{
|
|
mEyeDiagramDataTap = (EyeDiagramDataTap)tap;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void unregisterTap( Tap tap )
|
|
{
|
|
if( tap instanceof EyeDiagramDataTap )
|
|
{
|
|
mEyeDiagramDataTap = null;
|
|
}
|
|
}
|
|
}
|