mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
2537 Complex Vector Oscillator (256/512) Lanes Can Drift Apart Over Time (#2538)
* #2535 Heterodyne Channelizer enhancements and corrections. * #2537 Complex vector oscillator SIMD lane drift. Realigns the SIMD lanes every 262,144 samples. At frequencies where the rotation for a set of lanes is nearly a whole or half turn, rounding affects each lane differently and the lanes drift apart until the next frequency change, producing spurs at multiples of the sample rate divided by the lane count. Lane phase error at 3125 Hz with a 50 kHz sample rate was about -38 dBc after one hour and is now about -83 dBc with 16 lanes and -73 dBc with 8 lanes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #2537 test updates * #2537 test updates --------- Co-authored-by: denny <dennis.sheirer@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 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 java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* Verifies that channels sharing a native buffer receive the same converted samples.
|
||||
*/
|
||||
public class SharedNativeBufferTest
|
||||
{
|
||||
private static byte[] samples()
|
||||
{
|
||||
byte[] samples = new byte[8192 * 8];
|
||||
new Random(1).nextBytes(samples);
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static List<ComplexSamples> read(INativeBuffer buffer)
|
||||
{
|
||||
List<ComplexSamples> fragments = new ArrayList<>();
|
||||
Iterator<ComplexSamples> iterator = buffer.iterator();
|
||||
|
||||
while(iterator.hasNext())
|
||||
{
|
||||
fragments.add(iterator.next());
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the samples match the wrapped buffer and each consumer gets the same instances.
|
||||
*/
|
||||
@Test
|
||||
void consumersShareOneConversion()
|
||||
{
|
||||
ByteNativeBuffer nativeBuffer = new ByteNativeBuffer(samples(), 1000L, 0.0f, 2400.0f);
|
||||
SharedNativeBuffer shared = new SharedNativeBuffer(nativeBuffer);
|
||||
List<ComplexSamples> expected = read(nativeBuffer);
|
||||
List<ComplexSamples> first = read(shared);
|
||||
List<ComplexSamples> second = read(shared);
|
||||
|
||||
assertEquals(expected.size(), first.size());
|
||||
assertEquals(expected.size(), second.size());
|
||||
|
||||
for(int x = 0; x < expected.size(); x++)
|
||||
{
|
||||
assertArrayEquals(expected.get(x).i(), first.get(x).i());
|
||||
assertArrayEquals(expected.get(x).q(), first.get(x).q());
|
||||
assertEquals(expected.get(x).timestamp(), first.get(x).timestamp());
|
||||
assertSame(first.get(x), second.get(x));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: concurrent consumers each receive every fragment in order.
|
||||
*/
|
||||
@Test
|
||||
void concurrentConsumersReceiveAllFragments() throws Exception
|
||||
{
|
||||
ByteNativeBuffer nativeBuffer = new ByteNativeBuffer(samples(), 1000L, 0.0f, 2400.0f);
|
||||
int fragmentCount = read(nativeBuffer).size();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
|
||||
try
|
||||
{
|
||||
for(int trial = 0; trial < 200; trial++)
|
||||
{
|
||||
SharedNativeBuffer shared = new SharedNativeBuffer(nativeBuffer);
|
||||
List<Future<List<ComplexSamples>>> results = new ArrayList<>();
|
||||
|
||||
for(int consumer = 0; consumer < 4; consumer++)
|
||||
{
|
||||
results.add(executor.submit(() -> read(shared)));
|
||||
}
|
||||
|
||||
List<ComplexSamples> first = results.getFirst().get();
|
||||
assertEquals(fragmentCount, first.size());
|
||||
|
||||
for(Future<List<ComplexSamples>> result : results)
|
||||
{
|
||||
assertEquals(first, result.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 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.filter.decimate;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies decimation rate selection and the alias rejection of the decimation filters.
|
||||
*/
|
||||
public class DecimationFilterFactoryTest
|
||||
{
|
||||
private static final double BANDWIDTH = 12500.0;
|
||||
private static final double DECIMATED_SAMPLE_RATE = 25000.0;
|
||||
private static final double MINIMUM_ALIAS_REJECTION_DB = 68.0;
|
||||
|
||||
/**
|
||||
* Calculates the filter's gain for a real tone, from the output power after the filter settles.
|
||||
*/
|
||||
private static double gainDb(int decimation, double frequency, double sampleRate)
|
||||
{
|
||||
IRealDecimationFilter filter = DecimationFilterFactory.getRealDecimationFilter(decimation);
|
||||
int blockSize = Math.max(4096, decimation * 8);
|
||||
int blocks = Math.max(64, 262144 / blockSize);
|
||||
double phaseStep = 2.0 * Math.PI * frequency / sampleRate;
|
||||
long sample = 0;
|
||||
double power = 0.0;
|
||||
int count = 0;
|
||||
|
||||
for(int block = 0; block < blocks; block++)
|
||||
{
|
||||
float[] input = new float[blockSize];
|
||||
|
||||
for(int x = 0; x < blockSize; x++)
|
||||
{
|
||||
input[x] = (float)Math.cos(phaseStep * sample++);
|
||||
}
|
||||
|
||||
float[] output = filter.decimateReal(input);
|
||||
|
||||
if(block > blocks / 2)
|
||||
{
|
||||
for(float value : output)
|
||||
{
|
||||
power += value * value;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//A real tone with unity amplitude has an average power of 0.5
|
||||
return 10.0 * Math.log10(power / count / 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the requested rate is decimated by the largest supported rate that is less than the requested rate.
|
||||
*/
|
||||
@Test
|
||||
void decimationRate()
|
||||
{
|
||||
assertEquals(2, DecimationFilterFactory.getDecimationRate(3));
|
||||
assertEquals(2, DecimationFilterFactory.getDecimationRate(4));
|
||||
assertEquals(4, DecimationFilterFactory.getDecimationRate(5));
|
||||
assertEquals(64, DecimationFilterFactory.getDecimationRate(96));
|
||||
assertEquals(1024, DecimationFilterFactory.getDecimationRate(4096));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: rates that are too small to decimate apply no decimation, instead of a zero rate or a rate that decimates
|
||||
* below the requested sample rate.
|
||||
*/
|
||||
@Test
|
||||
void noDecimationWhenRateIsTooSmall()
|
||||
{
|
||||
for(int requestedRate : new int[]{0, 1, 2})
|
||||
{
|
||||
assertEquals(1, DecimationFilterFactory.getDecimationRate(requestedRate));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: negative rates are rejected.
|
||||
*/
|
||||
@Test
|
||||
void negativeRateIsRejected()
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> DecimationFilterFactory.getDecimationRate(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: half-band filters have unity gain at DC and a null at the Nyquist frequency, across filter lengths.
|
||||
*/
|
||||
@Test
|
||||
void halfBandDcGainAndNyquistNull()
|
||||
{
|
||||
for(int length : new int[]{11, 15, 23, 63})
|
||||
{
|
||||
float[] coefficients = FilterFactory.getHalfBand(length, WindowType.BLACKMAN);
|
||||
double dc = 0.0;
|
||||
double nyquist = 0.0;
|
||||
|
||||
for(int x = 0; x < coefficients.length; x++)
|
||||
{
|
||||
dc += coefficients[x];
|
||||
nyquist += (x % 2 == 0) ? coefficients[x] : -coefficients[x];
|
||||
}
|
||||
|
||||
assertEquals(1.0, dc, 1e-6);
|
||||
assertEquals(0.0, nyquist, 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: tones that alias into the channel are rejected and tones within the channel are passed, for the band edge
|
||||
* of the final stage and for tones near the Nyquist frequency of the first stage.
|
||||
*/
|
||||
@Test
|
||||
void aliasRejection()
|
||||
{
|
||||
for(int decimation = 2; decimation <= 512; decimation *= 2)
|
||||
{
|
||||
double sampleRate = DECIMATED_SAMPLE_RATE * decimation;
|
||||
|
||||
assertEquals(0.0, gainDb(decimation, BANDWIDTH / 2.0, sampleRate), 0.1);
|
||||
|
||||
//Closest alias into the channel at the final stage
|
||||
double finalStageAlias = DECIMATED_SAMPLE_RATE - (BANDWIDTH / 2.0);
|
||||
assertTrue(gainDb(decimation, finalStageAlias, sampleRate) < -MINIMUM_ALIAS_REJECTION_DB);
|
||||
|
||||
if(decimation > 2)
|
||||
{
|
||||
double firstStageAlias = (sampleRate / 2.0) - (BANDWIDTH / 2.0);
|
||||
assertTrue(gainDb(decimation, firstStageAlias, sampleRate) < -MINIMUM_ALIAS_REJECTION_DB);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 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.filter.decimate;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.halfband.RealHalfBandDecimationFilter;
|
||||
import io.github.dsheirer.dsp.mixer.ComplexMixer;
|
||||
import io.github.dsheirer.dsp.mixer.ScalarComplexMixer;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* Verifies that reusing arrays for intermediate results doesn't change the results.
|
||||
*/
|
||||
public class IntermediateOutputReuseTest
|
||||
{
|
||||
private static float[] samples(int length, long seed)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
float[] samples = new float[length];
|
||||
|
||||
for(int x = 0; x < length; x++)
|
||||
{
|
||||
samples[x] = random.nextFloat() - 0.5f;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a half-band filter uses the output array and produces the same results as an allocating filter.
|
||||
*/
|
||||
@Test
|
||||
void halfBandOutputArray()
|
||||
{
|
||||
float[] coefficients = FilterFactory.getHalfBand(23, WindowType.BLACKMAN);
|
||||
RealHalfBandDecimationFilter allocating = new RealHalfBandDecimationFilter(coefficients);
|
||||
RealHalfBandDecimationFilter reusing = new RealHalfBandDecimationFilter(coefficients);
|
||||
float[] output = new float[1024];
|
||||
|
||||
for(int block = 0; block < 4; block++)
|
||||
{
|
||||
float[] samples = samples(2048, block);
|
||||
assertSame(output, reusing.decimateReal(samples, output));
|
||||
assertArrayEquals(allocating.decimateReal(samples), output);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: consecutive results from a decimation cascade are independent arrays, since consumers retain them.
|
||||
*/
|
||||
@Test
|
||||
void cascadeOutputIsNotReused()
|
||||
{
|
||||
IRealDecimationFilter filter = DecimationFilterFactory.getRealDecimationFilter(16);
|
||||
float[] first = filter.decimateReal(samples(2048, 1));
|
||||
float[] copy = first.clone();
|
||||
float[] second = filter.decimateReal(samples(2048, 2));
|
||||
|
||||
assertNotSame(first, second);
|
||||
assertArrayEquals(copy, first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: mixing into reused arrays matches the allocating mix, and the input isn't modified.
|
||||
*/
|
||||
@Test
|
||||
void mixerOutputArrays()
|
||||
{
|
||||
ComplexMixer allocating = new ScalarComplexMixer(100000, 2400000);
|
||||
ComplexMixer reusing = new ScalarComplexMixer(100000, 2400000);
|
||||
float[] i = new float[2048];
|
||||
float[] q = new float[2048];
|
||||
|
||||
for(int block = 0; block < 4; block++)
|
||||
{
|
||||
ComplexSamples samples = new ComplexSamples(samples(2048, block), samples(2048, block + 10), block);
|
||||
float[] iOriginal = samples.i().clone();
|
||||
ComplexSamples expected = allocating.mix(samples);
|
||||
ComplexSamples mixed = reusing.mix(samples, i, q);
|
||||
|
||||
assertSame(i, mixed.i());
|
||||
assertSame(q, mixed.q());
|
||||
assertEquals(block, mixed.timestamp());
|
||||
assertArrayEquals(expected.i(), mixed.i());
|
||||
assertArrayEquals(expected.q(), mixed.q());
|
||||
assertArrayEquals(iOriginal, samples.i());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 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.oscillator;
|
||||
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies that the vector complex oscillator produces a clean tone, including across frequency changes.
|
||||
*/
|
||||
public class VectorComplexOscillatorTest
|
||||
{
|
||||
private static final double SAMPLE_RATE = 2_400_000.0;
|
||||
private static final int BUFFER_SIZE = FloatVector.SPECIES_PREFERRED.length() * 256;
|
||||
private static final double MAXIMUM_ERROR_RADIANS = 0.001;
|
||||
|
||||
/**
|
||||
* Largest deviation of the sample to sample rotation from the rotation expected for the frequency.
|
||||
*/
|
||||
private static double rotationError(ComplexSamples samples, double frequency)
|
||||
{
|
||||
double expected = 2.0 * Math.PI * frequency / SAMPLE_RATE;
|
||||
float[] i = samples.i();
|
||||
float[] q = samples.q();
|
||||
double error = 0.0;
|
||||
|
||||
for(int x = 1; x < i.length; x++)
|
||||
{
|
||||
double rotation = Math.atan2(q[x], i[x]) - Math.atan2(q[x - 1], i[x - 1]);
|
||||
error = Math.max(error, Math.abs(Math.IEEEremainder(rotation - expected, 2.0 * Math.PI)));
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: output is a unit magnitude tone at the requested frequency, for the mixer's sample generation method.
|
||||
*/
|
||||
@Test
|
||||
void generatesTone()
|
||||
{
|
||||
VectorComplexOscillator oscillator = new VectorComplexOscillator(400_000, SAMPLE_RATE);
|
||||
ComplexSamples samples = oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
|
||||
assertTrue(rotationError(samples, 400_000) < MAXIMUM_ERROR_RADIANS);
|
||||
|
||||
for(int x = 0; x < samples.i().length; x++)
|
||||
{
|
||||
assertEquals(1.0, Math.hypot(samples.i()[x], samples.q()[x]), 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the SIMD lanes follow a frequency change, without a phase discontinuity at the change.
|
||||
*/
|
||||
@Test
|
||||
void frequencyChangeIsPhaseContinuous()
|
||||
{
|
||||
VectorComplexOscillator oscillator = new VectorComplexOscillator(600_000, SAMPLE_RATE);
|
||||
ComplexSamples before = oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
oscillator.setFrequency(-300_000);
|
||||
ComplexSamples after = oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
|
||||
assertTrue(rotationError(after, -300_000) < MAXIMUM_ERROR_RADIANS);
|
||||
|
||||
int last = before.i().length - 1;
|
||||
double step = Math.atan2(after.q()[0], after.i()[0]) - Math.atan2(before.q()[last], before.i()[last]);
|
||||
double error = Math.abs(Math.IEEEremainder(step, 2.0 * Math.PI));
|
||||
assertTrue(error <= (2.0 * Math.PI * 600_000 / SAMPLE_RATE) * FloatVector.SPECIES_PREFERRED.length() + 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the SIMD lanes stay aligned over a long run at frequencies where the rotation for a set of lanes is nearly
|
||||
* a whole turn or half turn, which is small enough for rounding to affect each lane differently. The frequencies
|
||||
* that are affected depend on the lane width of the hardware.
|
||||
*/
|
||||
@Test
|
||||
void lanesRemainAlignedAtRationalFrequencies()
|
||||
{
|
||||
int buffers = 30_000_000 / BUFFER_SIZE;
|
||||
|
||||
for(double divisor: new double[]{8.0, 16.0, 32.0})
|
||||
{
|
||||
double frequency = SAMPLE_RATE / divisor;
|
||||
VectorComplexOscillator oscillator = new VectorComplexOscillator(frequency, SAMPLE_RATE);
|
||||
|
||||
for(int x = 0; x < buffers; x++)
|
||||
{
|
||||
assertTrue(rotationError(oscillator.generateComplexSamples(BUFFER_SIZE, 0), frequency) < MAXIMUM_ERROR_RADIANS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a frequency change from another thread during sample generation doesn't leave the SIMD lanes spaced for
|
||||
* the previous frequency.
|
||||
*/
|
||||
@Test
|
||||
void concurrentFrequencyChangeUpdatesLanes() throws Exception
|
||||
{
|
||||
for(int trial = 0; trial < 500; trial++)
|
||||
{
|
||||
VectorComplexOscillator oscillator = new VectorComplexOscillator(600_000, SAMPLE_RATE);
|
||||
oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
|
||||
Thread thread = new Thread(() -> oscillator.setFrequency(-300_000));
|
||||
thread.start();
|
||||
|
||||
while(thread.isAlive())
|
||||
{
|
||||
oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
}
|
||||
|
||||
//The first buffer can overlap the change, so that it is the second buffer that must be clean
|
||||
oscillator.generateComplexSamples(BUFFER_SIZE, 0);
|
||||
assertTrue(rotationError(oscillator.generateComplexSamples(BUFFER_SIZE, 0), -300_000) < MAXIMUM_ERROR_RADIANS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 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.channel;
|
||||
|
||||
import io.github.dsheirer.buffer.FloatNativeBuffer;
|
||||
import io.github.dsheirer.buffer.INativeBuffer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Verifies that the heterodyne channel source applies frequency updates on the sample processing thread.
|
||||
*/
|
||||
public class HalfBandTunerChannelSourceTest
|
||||
{
|
||||
private static final long CHANNEL_FREQUENCY = 460_000_000L;
|
||||
private static final long TUNER_FREQUENCY = 460_400_000L;
|
||||
private static final double SAMPLE_RATE = 2_400_000.0;
|
||||
|
||||
private static HalfBandTunerChannelSource<INativeBuffer> source() throws Exception
|
||||
{
|
||||
HalfBandTunerChannelSource<INativeBuffer> source = new HalfBandTunerChannelSource<>(sourceEvent -> {},
|
||||
new TunerChannel(CHANNEL_FREQUENCY, 12500), SAMPLE_RATE,
|
||||
new ChannelSpecification(25000.0, 12500, 6000.0, 7000.0), "test", null);
|
||||
source.setListener(samples -> {});
|
||||
return source;
|
||||
}
|
||||
|
||||
private static void process(HalfBandTunerChannelSource<INativeBuffer> source)
|
||||
{
|
||||
source.new NativeBufferProcessor().receive(new FloatNativeBuffer(new float[8192], 0L, 0.0f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the mixer only changes on the sample processing thread, using both the tuner frequency and the correction.
|
||||
*/
|
||||
@Test
|
||||
void frequencyUpdatesAreAppliedDuringSampleProcessing() throws Exception
|
||||
{
|
||||
HalfBandTunerChannelSource<INativeBuffer> source = source();
|
||||
|
||||
source.setFrequency(TUNER_FREQUENCY);
|
||||
source.setFrequencyCorrection(100);
|
||||
assertEquals(0, source.getMixerFrequency());
|
||||
|
||||
process(source);
|
||||
assertEquals(TUNER_FREQUENCY - CHANNEL_FREQUENCY + 100, source.getMixerFrequency());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a sample rate that is too low to decimate passes through at the original sample rate.
|
||||
*/
|
||||
@Test
|
||||
void lowSampleRateIsNotDecimated() throws Exception
|
||||
{
|
||||
HalfBandTunerChannelSource<INativeBuffer> source = new HalfBandTunerChannelSource<>(sourceEvent -> {},
|
||||
new TunerChannel(CHANNEL_FREQUENCY, 12500), 50000.0,
|
||||
new ChannelSpecification(50000.0, 12500, 6500.0, 7200.0), "test", null);
|
||||
|
||||
assertEquals(50000.0, source.getSampleRate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: concurrent tuner frequency and channel correction updates are both reflected in the mixer.
|
||||
*/
|
||||
@Test
|
||||
void concurrentUpdatesAreNotLost() throws Exception
|
||||
{
|
||||
for(int trial = 0; trial < 1000; trial++)
|
||||
{
|
||||
HalfBandTunerChannelSource<INativeBuffer> source = source();
|
||||
Thread tuner = new Thread(() -> source.setFrequency(TUNER_FREQUENCY));
|
||||
Thread correction = new Thread(() -> source.setFrequencyCorrection(100));
|
||||
tuner.start();
|
||||
correction.start();
|
||||
tuner.join();
|
||||
correction.join();
|
||||
|
||||
process(source);
|
||||
assertEquals(TUNER_FREQUENCY - CHANNEL_FREQUENCY + 100, source.getMixerFrequency());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.manager;
|
||||
|
||||
import io.github.dsheirer.buffer.FloatNativeBuffer;
|
||||
import io.github.dsheirer.buffer.INativeBuffer;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import io.github.dsheirer.source.SourceException;
|
||||
import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
||||
import io.github.dsheirer.source.tuner.test.TestTunerController;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies the channel start/stop lifecycle of the heterodyne channel source manager.
|
||||
*/
|
||||
public class HeterodyneChannelSourceManagerTest
|
||||
{
|
||||
private static final long FREQUENCY = 460_000_000L;
|
||||
private static final ChannelSpecification SPECIFICATION = new ChannelSpecification(25000.0, 12500, 6000.0, 7000.0);
|
||||
|
||||
/**
|
||||
* Tuner controller that tracks sample buffer listeners, without generating any sample buffers.
|
||||
*/
|
||||
private static class ListenerTrackingTunerController extends TestTunerController
|
||||
{
|
||||
private final Set<Listener<INativeBuffer>> mListeners = new HashSet<>();
|
||||
private boolean mTuningFails = false;
|
||||
|
||||
/**
|
||||
* Sends a sample buffer to each of the listeners.
|
||||
*/
|
||||
void deliver(long timestamp)
|
||||
{
|
||||
new ArrayList<>(mListeners).forEach(listener -> listener.receive(new FloatNativeBuffer(new float[4096],
|
||||
timestamp, 0.0f)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFrequency(long frequency) throws SourceException
|
||||
{
|
||||
if(mTuningFails)
|
||||
{
|
||||
throw new SourceException("Tuner frequency cannot be set");
|
||||
}
|
||||
|
||||
super.setFrequency(frequency);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addBufferListener(Listener<INativeBuffer> listener)
|
||||
{
|
||||
mListeners.add(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeBufferListener(Listener<INativeBuffer> listener)
|
||||
{
|
||||
mListeners.remove(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBufferListeners()
|
||||
{
|
||||
return !mListeners.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static TunerChannelSource source(HeterodyneChannelSourceManager manager, long frequency)
|
||||
{
|
||||
TunerChannelSource source = manager.getSource(new TunerChannel(frequency, 12500), SPECIFICATION, "test");
|
||||
assertNotNull(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: the tuner stops streaming when the last channel stops, before any further sample buffers arrive.
|
||||
*/
|
||||
@Test
|
||||
void lastChannelStopDeregistersFromTuner()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
TunerChannelSource first = source(manager, FREQUENCY);
|
||||
TunerChannelSource second = source(manager, FREQUENCY + 25000);
|
||||
|
||||
first.start();
|
||||
second.start();
|
||||
assertTrue(controller.hasBufferListeners());
|
||||
|
||||
first.stop();
|
||||
assertTrue(controller.hasBufferListeners(), "tuner keeps streaming while a channel is running");
|
||||
|
||||
second.stop();
|
||||
assertFalse(controller.hasBufferListeners(), "tuner stops streaming after the last channel stops");
|
||||
assertEquals(0, manager.getTunerChannelCount());
|
||||
assertFalse(controller.isLockedSampleRate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: streaming restarts for a channel that starts after the previous last channel stopped.
|
||||
*/
|
||||
@Test
|
||||
void channelStartAfterLastChannelStopRegistersWithTuner()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
|
||||
TunerChannelSource first = source(manager, FREQUENCY);
|
||||
first.start();
|
||||
first.stop();
|
||||
|
||||
TunerChannelSource second = source(manager, FREQUENCY);
|
||||
second.start();
|
||||
assertTrue(controller.hasBufferListeners());
|
||||
second.stop();
|
||||
assertFalse(controller.hasBufferListeners());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: no channel is provided when the tuner can't retune for it, and the running channel is unaffected.
|
||||
*/
|
||||
@Test
|
||||
void failedRetuneDoesNotProvideChannel()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
TunerChannelSource running = source(manager, FREQUENCY);
|
||||
running.start();
|
||||
|
||||
controller.mTuningFails = true;
|
||||
|
||||
//Within the tuner bandwidth of the running channel, but outside of the current center frequency's bandwidth
|
||||
assertNull(manager.getSource(new TunerChannel(FREQUENCY + 2_000_000L, 12500), SPECIFICATION, "test"));
|
||||
assertEquals(1, manager.getTunerChannelCount());
|
||||
assertTrue(controller.isLockedSampleRate());
|
||||
running.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: a channel started with a grant timestamp is preloaded with the delayed samples from that timestamp forward,
|
||||
* and in order, ahead of the current samples.
|
||||
*/
|
||||
@Test
|
||||
void channelIsPreloadedFromStartTimestamp() throws Exception
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
TunerChannelSource running = source(manager, FREQUENCY);
|
||||
running.start();
|
||||
|
||||
//The first buffer applies the running channel's pending start request
|
||||
for(long timestamp = 1000; timestamp <= 5000; timestamp += 1000)
|
||||
{
|
||||
controller.deliver(timestamp);
|
||||
}
|
||||
|
||||
TunerChannelSource traffic = source(manager, FREQUENCY + 25000);
|
||||
CountDownLatch latch = new CountDownLatch(4);
|
||||
List<Long> timestamps = new CopyOnWriteArrayList<>();
|
||||
traffic.setListener((ComplexSamples samples) -> {
|
||||
timestamps.add(samples.timestamp());
|
||||
latch.countDown();
|
||||
});
|
||||
traffic.setStartTimestamp(3000);
|
||||
traffic.start();
|
||||
|
||||
controller.deliver(6000);
|
||||
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(List.of(3000L, 4000L, 5000L, 6000L), timestamps);
|
||||
|
||||
traffic.stop();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: channels that share a frequency are counted separately, and the tuner channel, the sample rate lock and the
|
||||
* tuner stream remain until the last of those channels stops.
|
||||
*/
|
||||
@Test
|
||||
void channelsWithSameFrequencyAreTrackedSeparately()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
TunerChannelSource first = source(manager, FREQUENCY);
|
||||
TunerChannelSource second = source(manager, FREQUENCY);
|
||||
first.start();
|
||||
second.start();
|
||||
assertEquals(2, manager.getTunerChannelCount());
|
||||
|
||||
first.stop();
|
||||
assertEquals(1, manager.getTunerChannelCount());
|
||||
assertEquals(1, manager.getTunerChannels().size());
|
||||
assertTrue(controller.isLockedSampleRate());
|
||||
assertTrue(controller.hasBufferListeners());
|
||||
|
||||
second.stop();
|
||||
assertEquals(0, manager.getTunerChannelCount());
|
||||
assertTrue(manager.getTunerChannels().isEmpty());
|
||||
assertFalse(controller.isLockedSampleRate());
|
||||
assertFalse(controller.hasBufferListeners());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: stopping a channel that was never started releases its tuner channel.
|
||||
*/
|
||||
@Test
|
||||
void stopWithoutStartReleasesTunerChannel()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
|
||||
source(manager, FREQUENCY).stop();
|
||||
|
||||
assertEquals(0, manager.getTunerChannelCount());
|
||||
assertFalse(controller.isLockedSampleRate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: stopping a never started channel doesn't stop the sample buffers for a running channel.
|
||||
*/
|
||||
@Test
|
||||
void stopWithoutStartLeavesRunningChannelStreaming()
|
||||
{
|
||||
ListenerTrackingTunerController controller = new ListenerTrackingTunerController();
|
||||
HeterodyneChannelSourceManager manager = new HeterodyneChannelSourceManager(controller);
|
||||
TunerChannelSource running = source(manager, FREQUENCY);
|
||||
TunerChannelSource neverStarted = source(manager, FREQUENCY + 25000);
|
||||
|
||||
running.start();
|
||||
neverStarted.stop();
|
||||
|
||||
assertTrue(controller.hasBufferListeners());
|
||||
running.stop();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user