mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -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,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