From 866aadc8c35d34c902bd12facaef07faddbd38c5 Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Sun, 1 Nov 2026 04:24:22 -0500 Subject: [PATCH] ##2544 bladeRF supports up to 122 MHz sample rate. Adds new 2048 and 4096 half band decimation filters. (#2545) Co-authored-by: denny --- .../decimate/DecimationFilterFactory.java | 10 +- .../decimate/RealDecimateX2048Filter.java | 54 +++++++ .../decimate/RealDecimateX4096Filter.java | 54 +++++++ .../tuner/bladerf/BladeRfSampleRate.java | 35 +++- .../source/tuner/bladerf/BladeRfTuner.java | 7 +- .../tuner/bladerf/BladeRfTunerController.java | 151 +++++++++++++++++- .../tuner/bladerf/BladeRfTunerEditor.java | 9 +- .../channel/HalfBandTunerChannelSource.java | 132 +++++++++++++-- .../decimate/DecimationFilterFactoryTest.java | 22 ++- .../tuner/bladerf/BladeRfTunerTest.java | 77 ++++++++- .../HalfBandTunerChannelSourceTest.java | 140 ++++++++++++++++ 11 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX2048Filter.java create mode 100644 src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX4096Filter.java diff --git a/src/main/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactory.java b/src/main/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactory.java index 8a5ed842..abdff915 100644 --- a/src/main/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactory.java +++ b/src/main/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactory.java @@ -29,12 +29,14 @@ import org.slf4j.LoggerFactory; public class DecimationFilterFactory { private static final Logger mLog = LoggerFactory.getLogger(DecimationFilterFactory.class); - private static final int[] SUPPORTED_RATES = new int[]{1,2,4,8,16,32,64,128,256,512,1024}; + public static final int MAXIMUM_DECIMATION_RATE = 4096; + private static final int[] SUPPORTED_RATES = new int[]{1,2,4,8,16,32,64,128,256,512,1024,2048,MAXIMUM_DECIMATION_RATE}; /** * Creates a real-valued decimation filter for float array sample buffers providing approximately 70 dB of * attenuation for out of band signal aliases, when the signal bandwidth is no more than half of the decimated - * sample rate. Supports power of 2 decimation rates up to 1024. + * sample rate. Supports power of 2 decimation rates up to 4096. Sample buffers must be an integer multiple of the + * decimation rate. * * @param decimationRate requested @see SUPPORTED_RATES * @return constructed decimation filter @@ -66,6 +68,10 @@ public class DecimationFilterFactory return new RealDecimateX512Filter(); case 1024: return new RealDecimateX1024Filter(); + case 2048: + return new RealDecimateX2048Filter(); + case 4096: + return new RealDecimateX4096Filter(); default: throw new IllegalArgumentException("Unsupported decimation rate: " + decimationRate + ". Supported decimation rates are:" + Arrays.toString(SUPPORTED_RATES)); diff --git a/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX2048Filter.java b/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX2048Filter.java new file mode 100644 index 00000000..253e7269 --- /dev/null +++ b/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX2048Filter.java @@ -0,0 +1,54 @@ +/* + * ***************************************************************************** + * 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 + * **************************************************************************** + */ + +package io.github.dsheirer.dsp.filter.decimate; + +import io.github.dsheirer.dsp.filter.FilterFactory; +import io.github.dsheirer.dsp.window.WindowType; + +/** + * Decimate by 2048 filter for real valued sample buffers. + */ +public class RealDecimateX2048Filter extends RealDecimateX1024Filter +{ + private static final int VALIDATION_LENGTH = 2048; + private static final int DECIMATE_BY_2048_FILTER_LENGTH = 11; + private static final WindowType DECIMATE_BY_2048_WINDOW_TYPE = WindowType.BLACKMAN; + private IRealDecimationFilter mFilter; + private float[] mOutput; + + /** + * Constructs the decimation filter. + */ + public RealDecimateX2048Filter() + { + mFilter = FilterFactory.getRealDecimationFilter(DECIMATE_BY_2048_FILTER_LENGTH, + DECIMATE_BY_2048_WINDOW_TYPE); + } + + @Override + public float[] decimateReal(float[] samples) + { + validate(samples, VALIDATION_LENGTH); + + //Decimate by this filter, then by the parent decimation filter + mOutput = getIntermediateOutput(mOutput, samples.length / 2); + return super.decimateReal(mFilter.decimateReal(samples, mOutput)); + } +} diff --git a/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX4096Filter.java b/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX4096Filter.java new file mode 100644 index 00000000..9a126364 --- /dev/null +++ b/src/main/java/io/github/dsheirer/dsp/filter/decimate/RealDecimateX4096Filter.java @@ -0,0 +1,54 @@ +/* + * ***************************************************************************** + * 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 + * **************************************************************************** + */ + +package io.github.dsheirer.dsp.filter.decimate; + +import io.github.dsheirer.dsp.filter.FilterFactory; +import io.github.dsheirer.dsp.window.WindowType; + +/** + * Decimate by 4096 filter for real valued sample buffers. + */ +public class RealDecimateX4096Filter extends RealDecimateX2048Filter +{ + private static final int VALIDATION_LENGTH = 4096; + private static final int DECIMATE_BY_4096_FILTER_LENGTH = 11; + private static final WindowType DECIMATE_BY_4096_WINDOW_TYPE = WindowType.BLACKMAN; + private IRealDecimationFilter mFilter; + private float[] mOutput; + + /** + * Constructs the decimation filter. + */ + public RealDecimateX4096Filter() + { + mFilter = FilterFactory.getRealDecimationFilter(DECIMATE_BY_4096_FILTER_LENGTH, + DECIMATE_BY_4096_WINDOW_TYPE); + } + + @Override + public float[] decimateReal(float[] samples) + { + validate(samples, VALIDATION_LENGTH); + + //Decimate by this filter, then by the parent decimation filter + mOutput = getIntermediateOutput(mOutput, samples.length / 2); + return super.decimateReal(mFilter.decimateReal(samples, mOutput)); + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfSampleRate.java b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfSampleRate.java index 8307a476..20e91eb2 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfSampleRate.java +++ b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfSampleRate.java @@ -23,7 +23,14 @@ import java.util.ArrayList; import java.util.List; /** - * Sample rates offered for bladeRF tuners. Each is a multiple of the 25 kHz polyphase channel spacing. + * Sample rates offered for bladeRF tuners, up to the 122.88 MHz maximum of the bladeRF 2.0. Use getRates() to get the + * rates supported by a specific device, since the bladeRF 1.0, USB 2.0 connections and older FPGA images have lower + * maximums. + * + * Rates above 61.44 MHz use the AD9361 oversample feature, which requires 8-bit samples. + * + * Each rate is a multiple of the 25 kHz polyphase channel spacing, except for the 61.44 and 122.88 MHz rates. The + * channelizer uses slightly wider channels at those rates. */ public enum BladeRfSampleRate { @@ -36,7 +43,21 @@ public enum BladeRfSampleRate RATE_15_0(15_000_000, "15.00 MHz"), RATE_20_0(20_000_000, "20.00 MHz"), RATE_25_0(25_000_000, "25.00 MHz"), - RATE_30_0(30_000_000, "30.00 MHz"); + RATE_30_0(30_000_000, "30.00 MHz"), + RATE_35_0(35_000_000, "35.00 MHz"), + RATE_40_0(40_000_000, "40.00 MHz"), + RATE_45_0(45_000_000, "45.00 MHz"), + RATE_50_0(50_000_000, "50.00 MHz"), + RATE_55_0(55_000_000, "55.00 MHz"), + RATE_60_0(60_000_000, "60.00 MHz"), + RATE_61_44(61_440_000, "61.44 MHz"), + RATE_70_0(70_000_000, "70.00 MHz (8-bit)"), + RATE_80_0(80_000_000, "80.00 MHz (8-bit)"), + RATE_90_0(90_000_000, "90.00 MHz (8-bit)"), + RATE_100_0(100_000_000, "100.00 MHz (8-bit)"), + RATE_110_0(110_000_000, "110.00 MHz (8-bit)"), + RATE_120_0(120_000_000, "120.00 MHz (8-bit)"), + RATE_122_88(122_880_000, "122.88 MHz (8-bit)"); public static final BladeRfSampleRate DEFAULT = RATE_10_0; private final int mRate; @@ -56,6 +77,14 @@ public enum BladeRfSampleRate return mRate; } + /** + * Indicates if this rate requires the oversample feature and 8-bit samples. + */ + public boolean isOversampled() + { + return mRate > RATE_61_44.mRate; + } + @Override public String toString() { @@ -63,7 +92,7 @@ public enum BladeRfSampleRate } /** - * Sample rates within the device maximum, which is the lower of the hardware limit and what the USB link sustains. + * Sample rates within the device maximum, which is the lower of what the hardware, FPGA image and USB link support. * @param maximumRate in Hertz * @return rates, lowest to highest */ diff --git a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTuner.java b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTuner.java index 1ffcbf1c..977c73d0 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTuner.java +++ b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTuner.java @@ -30,6 +30,8 @@ import io.github.dsheirer.source.tuner.TunerClass; public class BladeRfTuner extends Tuner { private static final int BITS_PER_SAMPLE = 32; + private static final double SAMPLE_SIZE_12_BIT = 12.0; + private static final double SAMPLE_SIZE_8_BIT = 8.0; /** * Constructs an instance @@ -72,12 +74,13 @@ public class BladeRfTuner extends Tuner @Override public double getSampleSize() { - return 12.0; + BladeRfSampleRate rate = getController().getBladeRfSampleRate(); + return rate != null && rate.isOversampled() ? SAMPLE_SIZE_8_BIT : SAMPLE_SIZE_12_BIT; } @Override public int getMaximumUSBBitsPerSecond() { - return BladeRfSampleRate.RATE_30_0.getRate() * BITS_PER_SAMPLE; + return BladeRfSampleRate.RATE_61_44.getRate() * BITS_PER_SAMPLE; } } diff --git a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerController.java index 2a5354e6..2200d279 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerController.java @@ -28,14 +28,17 @@ import io.github.dsheirer.bladerf.Channel; import io.github.dsheirer.bladerf.ChannelLayout; import io.github.dsheirer.bladerf.DeviceInfo; import io.github.dsheirer.bladerf.DeviceSpeed; +import io.github.dsheirer.bladerf.Feature; import io.github.dsheirer.bladerf.GainMode; import io.github.dsheirer.bladerf.GainModeInfo; import io.github.dsheirer.bladerf.Range; import io.github.dsheirer.bladerf.SampleBuffer; import io.github.dsheirer.bladerf.SampleFormat; import io.github.dsheirer.bladerf.StreamConfig; +import io.github.dsheirer.bladerf.Version; import io.github.dsheirer.buffer.FloatNativeBuffer; import io.github.dsheirer.buffer.INativeBuffer; +import io.github.dsheirer.buffer.SignedByteNativeBuffer; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.source.SourceException; import io.github.dsheirer.source.tuner.ITunerErrorListener; @@ -62,6 +65,12 @@ import org.usb4java.LibUsb; * * Only the first receive channel (RX1 connector) is used. The bladeRF 2.0 has a second receive channel, but it shares * the local oscillator and sample rate with the first, so it couldn't be tuned as an independent tuner. + * + * Sample rates above 61.44 MHz use the AD9361 oversample feature with 8-bit samples. These are only offered with FPGA + * version 0.15.0 or later, because starting an 8-bit sample stream on an older FPGA crashes in libbladeRF. + * + * The device is reopened when changing between oversampled and normal sample rates. Disabling the oversample feature + * doesn't fully restore the RFIC. The device reports the correct settings, but streams at the wrong rate or times out. */ public class BladeRfTunerController extends TunerController { @@ -75,10 +84,16 @@ public class BladeRfTunerController extends TunerController private static final Channel CHANNEL = Channel.RX0; private static final float SAMPLE_SCALE = 1.0f / 2048.0f; private static final SampleFormat SAMPLE_FORMAT = SampleFormat.SC16_Q11; + private static final SampleFormat OVERSAMPLE_SAMPLE_FORMAT = SampleFormat.SC8_Q7; + private static final int OVERSAMPLE_MINIMUM_FPGA_MAJOR = 0; + private static final int OVERSAMPLE_MINIMUM_FPGA_MINOR = 15; private static final int BUFFER_SAMPLE_COUNT = 65536; private static final StreamConfig STREAM_CONFIG = new StreamConfig(32, BUFFER_SAMPLE_COUNT, 16); + private static final StreamConfig OVERSAMPLE_STREAM_CONFIG = new StreamConfig(64, BUFFER_SAMPLE_COUNT, 32); private static final long USB2_MAXIMUM_BYTES_PER_SECOND = 32_000_000L; private static final double MAXIMUM_USABLE_BANDWIDTH_PERCENT = 0.90; + //Measured with a carrier swept across the band at 122.88 MHz. The analog filter is 3 dB down at about +/- 33 MHz. + private static final double OVERSAMPLE_USABLE_BANDWIDTH_HZ = 66_000_000.0; private static final long STREAM_CHECK_INTERVAL_SECONDS = 1; private final int mBus; @@ -89,6 +104,7 @@ public class BladeRfTunerController extends TunerController private ScheduledFuture mStreamCheck; private List mSampleRates = List.of(); private BladeRfSampleRate mSampleRate; + private boolean mOversampled; private List mGainModes = List.of(); private Range mGainRange; private long mMinimumTunableFrequency = MINIMUM_TUNABLE_FREQUENCY_HZ; @@ -136,6 +152,8 @@ public class BladeRfTunerController extends TunerController try { mDevice = BladeRf.open(findDevice()); + mSampleRate = null; + mOversampled = false; if(!mDevice.isFpgaConfigured()) { @@ -270,12 +288,69 @@ public class BladeRfTunerController extends TunerController if(device.speed() != DeviceSpeed.SUPER) { - maximum = Math.min(maximum, USB2_MAXIMUM_BYTES_PER_SECOND / SAMPLE_FORMAT.bytesPerSample()); + return Math.min(maximum, USB2_MAXIMUM_BYTES_PER_SECOND / SAMPLE_FORMAT.bytesPerSample()); + } + + if(isOversampleSupported(device.fpgaVersion())) + { + maximum = Math.max(maximum, getOversampleMaximumSampleRate(device)); + } + else + { + mLog.info("bladeRF sample rates limited to [" + maximum + "] Hz - FPGA version [" + device.fpgaVersion() + + "] doesn't support 8-bit samples. Upgrade to FPGA version 0.15.0 or later for higher sample rates."); } return maximum; } + /** + * Indicates if the FPGA version supports the 8-bit samples that are required for the oversample feature. + * @param fpga version + * @return true if supported + */ + static boolean isOversampleSupported(Version fpga) + { + return fpga.major() > OVERSAMPLE_MINIMUM_FPGA_MAJOR || + (fpga.major() == OVERSAMPLE_MINIMUM_FPGA_MAJOR && fpga.minor() >= OVERSAMPLE_MINIMUM_FPGA_MINOR); + } + + /** + * Usable portion of an oversampled rate, which is limited by the analog filter that stays in the signal path. + * @param rate that is oversampled + * @return usable bandwidth percentage + */ + static double getOversampleUsableBandwidthPercentage(BladeRfSampleRate rate) + { + return Math.min(MAXIMUM_USABLE_BANDWIDTH_PERCENT, OVERSAMPLE_USABLE_BANDWIDTH_HZ / rate.getRate()); + } + + /** + * Highest sample rate that the device reports while the oversample feature is enabled. + * @return maximum rate, or zero if the device doesn't have the oversample feature, such as the bladeRF 1.0. + */ + private static long getOversampleMaximumSampleRate(BladeRfDevice device) throws BladeRfException + { + try + { + device.enableFeature(Feature.OVERSAMPLE, true); + } + catch(BladeRfException e) + { + mLog.info("bladeRF oversample feature isn't available - " + e.getMessage()); + return 0; + } + + try + { + return device.sampleRateRange(CHANNEL).max(); + } + finally + { + device.enableFeature(Feature.DEFAULT, true); + } + } + /** * Finds the libbladeRF description of the device at this controller's USB bus and port. */ @@ -450,11 +525,33 @@ public class BladeRfTunerController extends TunerController try { + if(rate.isOversampled() != mOversampled) + { + reopenDevice(); + + if(rate.isOversampled()) + { + //Bandwidth has to be set first since it can't be reliably set with oversample enabled. + mDevice.setBandwidth(CHANNEL, mDevice.bandwidthRange(CHANNEL).max()); + mDevice.enableFeature(Feature.OVERSAMPLE, true); + mOversampled = true; + } + } + mDevice.setSampleRate(CHANNEL, rate.getRate()); - long requested = (long)(rate.getRate() * MAXIMUM_USABLE_BANDWIDTH_PERCENT); - long bandwidth = mDevice.setBandwidth(CHANNEL, requested); - setUsableBandwidthPercentage(Math.min(MAXIMUM_USABLE_BANDWIDTH_PERCENT, - (double)bandwidth / rate.getRate())); + + if(rate.isOversampled()) + { + setUsableBandwidthPercentage(getOversampleUsableBandwidthPercentage(rate)); + } + else + { + long requested = (long)(rate.getRate() * MAXIMUM_USABLE_BANDWIDTH_PERCENT); + long bandwidth = mDevice.setBandwidth(CHANNEL, requested); + setUsableBandwidthPercentage(Math.min(MAXIMUM_USABLE_BANDWIDTH_PERCENT, + (double)bandwidth / rate.getRate())); + } + mSampleRate = rate; mSamplesPerMillisecond = rate.getRate() / 1000.0f; mFrequencyController.setSampleRate(rate.getRate()); @@ -465,7 +562,7 @@ public class BladeRfTunerController extends TunerController } finally { - if(streaming) + if(streaming && mDevice != null) { startStream(); } @@ -478,6 +575,33 @@ public class BladeRfTunerController extends TunerController } } + /** + * Closes and reopens the device and restores the settings that were reset, except for the sample rate and bandwidth + * which the caller sets. The sample stream must be stopped. + */ + private void reopenDevice() throws SourceException, BladeRfException + { + long frequency = mFrequencyController.getTunedFrequency(); + BladeRfDevice device = mDevice; + mDevice = null; + mOversampled = false; + closeDevice(device, false); + + mDevice = BladeRf.open(findDevice()); + mDevice.setFrequency(CHANNEL, frequency); + mDevice.setGainMode(CHANNEL, mGainMode); + + if(mGainMode == GainMode.MANUAL) + { + mDevice.setGain(CHANNEL, mGain); + } + + if(mBiasT) + { + mDevice.setBiasTee(CHANNEL, true); + } + } + /** * Gain modes supported by the device. */ @@ -679,7 +803,9 @@ public class BladeRfTunerController extends TunerController { try { - mStream = mDevice.startRx(ChannelLayout.RX_X1, SAMPLE_FORMAT, STREAM_CONFIG, this::receive); + mStream = mOversampled ? + mDevice.startRx(ChannelLayout.RX_X1, OVERSAMPLE_SAMPLE_FORMAT, OVERSAMPLE_STREAM_CONFIG, this::receive) : + mDevice.startRx(ChannelLayout.RX_X1, SAMPLE_FORMAT, STREAM_CONFIG, this::receive); mStreamCheck = ThreadPool.SCHEDULED.scheduleAtFixedRate(this::checkStream, STREAM_CHECK_INTERVAL_SECONDS, STREAM_CHECK_INTERVAL_SECONDS, TimeUnit.SECONDS); } @@ -746,7 +872,16 @@ public class BladeRfTunerController extends TunerController { try { - broadcast(new FloatNativeBuffer(scale(buffer.copyShorts()), System.currentTimeMillis(), mSamplesPerMillisecond)); + long timestamp = System.currentTimeMillis(); + + if(buffer.format() == OVERSAMPLE_SAMPLE_FORMAT) + { + broadcast(new SignedByteNativeBuffer(buffer.copyBytes(), timestamp, 0.0f, 0.0f, mSamplesPerMillisecond)); + } + else + { + broadcast(new FloatNativeBuffer(scale(buffer.copyShorts()), timestamp, mSamplesPerMillisecond)); + } } catch(RuntimeException e) { diff --git a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerEditor.java index 853605df..0074c8d2 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerEditor.java +++ b/src/main/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerEditor.java @@ -90,7 +90,7 @@ public class BladeRfTunerEditor extends TunerEditor extends TunerCh //Maximum number of filled buffers for the blocking queue private static final int BUFFER_MAX_CAPACITY = 600; + //Decimation rate where a 2048 sample fragment produces too few samples for the consumer, so the decimated samples + //are assembled into longer buffers. Decoders apply additional decimation that requires a buffer length that's a + //multiple of their decimation. + private static final int ASSEMBLED_OUTPUT_DECIMATION = 1024; + private static final int ASSEMBLED_OUTPUT_LENGTH = 64; + + //Decimation rate where sample fragments can be too short for the filter, which requires a buffer length that's a + //multiple of the decimation rate, so the mixed samples are assembled before decimation. + private static final int ASSEMBLED_INPUT_DECIMATION = 2048; + private final Dispatcher mBufferDispatcher; private final ComplexMixer mFrequencyCorrectionMixer; private final IRealDecimationFilter mIDecimationFilter; @@ -59,6 +69,15 @@ public class HalfBandTunerChannelSource extends TunerCh private final long mFrequencyOffset; private volatile long mFrequencyCorrection; private final AtomicBoolean mPendingMixerUpdate = new AtomicBoolean(); + private final boolean mAssembleOutput; + private final float[] mInputI; + private final float[] mInputQ; + private int mInputPointer; + private long mInputTimestamp; + private float[] mOutputI; + private float[] mOutputQ; + private int mOutputPointer; + private long mOutputTimestamp; /** * Constructs a frequency translating and CIC decimating channel source. @@ -87,6 +106,9 @@ public class HalfBandTunerChannelSource extends TunerCh //The decimation filters copy the mixed samples, unless there's no decimation and the mixed samples are the output mReuseMixerOutput = decimation > 1; + mAssembleOutput = decimation >= ASSEMBLED_OUTPUT_DECIMATION; + mInputI = decimation >= ASSEMBLED_INPUT_DECIMATION ? new float[decimation] : null; + mInputQ = decimation >= ASSEMBLED_INPUT_DECIMATION ? new float[decimation] : null; //Set dispatcher to process 1/10 of estimated sample arrival rate, 20 times per second (up to 200% per interval) mBufferDispatcher = new Dispatcher(threadName, 50, getHeartbeatManager(), BUFFER_MAX_CAPACITY); @@ -242,23 +264,113 @@ public class HalfBandTunerChannelSource extends TunerCh } ComplexSamples basebanded = mix(iterator.next()); - float[] i = mIDecimationFilter.decimateReal(basebanded.i()); - float[] q = mQDecimationFilter.decimateReal(basebanded.q()); - try + if(mInputI != null) { - mSamplesListener.receive(new ComplexSamples(i, q, basebanded.timestamp())); + assembleInput(basebanded); } - catch(Throwable t) + else { - //The listener can be made null and cause the error - only log if we have a non-null listener - if(mSamplesListener != null) - { - mLog.error("Error dispatching complex samples to listener [" + mSamplesListener + "]"); - } + decimate(basebanded.i(), basebanded.q(), basebanded.timestamp()); } } } } + + /** + * Assembles the mixed samples into buffers of the decimation rate length and decimates each full buffer. + * @param samples that were mixed, which are copied since the mixer output arrays can be reused. + */ + private void assembleInput(ComplexSamples samples) + { + int pointer = 0; + + while(pointer < samples.i().length) + { + if(mInputPointer == 0) + { + mInputTimestamp = samples.timestamp(); + } + + int length = Math.min(samples.i().length - pointer, mInputI.length - mInputPointer); + System.arraycopy(samples.i(), pointer, mInputI, mInputPointer, length); + System.arraycopy(samples.q(), pointer, mInputQ, mInputPointer, length); + pointer += length; + mInputPointer += length; + + if(mInputPointer == mInputI.length) + { + mInputPointer = 0; + decimate(mInputI, mInputQ, mInputTimestamp); + } + } + } + + /** + * Decimates the samples and sends them to the listener. + */ + private void decimate(float[] i, float[] q, long timestamp) + { + output(mIDecimationFilter.decimateReal(i), mQDecimationFilter.decimateReal(q), timestamp); + } + + /** + * Sends the decimated samples to the listener, assembling them into larger buffers at the maximum decimation. + * @param i decimated samples + * @param q decimated samples + * @param timestamp of the first sample + */ + private void output(float[] i, float[] q, long timestamp) + { + if(!mAssembleOutput) + { + dispatch(new ComplexSamples(i, q, timestamp)); + return; + } + + int pointer = 0; + + while(pointer < i.length) + { + if(mOutputPointer == 0) + { + //New arrays each time since the assembled arrays are passed to the consumer + mOutputI = new float[ASSEMBLED_OUTPUT_LENGTH]; + mOutputQ = new float[ASSEMBLED_OUTPUT_LENGTH]; + mOutputTimestamp = timestamp; + } + + int length = Math.min(i.length - pointer, ASSEMBLED_OUTPUT_LENGTH - mOutputPointer); + System.arraycopy(i, pointer, mOutputI, mOutputPointer, length); + System.arraycopy(q, pointer, mOutputQ, mOutputPointer, length); + pointer += length; + mOutputPointer += length; + + if(mOutputPointer == ASSEMBLED_OUTPUT_LENGTH) + { + mOutputPointer = 0; + dispatch(new ComplexSamples(mOutputI, mOutputQ, mOutputTimestamp)); + } + } + } + + /** + * Sends the samples to the listener. + */ + private void dispatch(ComplexSamples samples) + { + try + { + mSamplesListener.receive(samples); + } + catch(Throwable t) + { + //The listener can be made null and cause the error - only log if we have a non-null listener + if(mSamplesListener != null) + { + mLog.error("Error dispatching complex samples to listener [" + mSamplesListener + "]"); + } + } + } } } diff --git a/src/test/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactoryTest.java b/src/test/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactoryTest.java index 93928cc1..1f2846ee 100644 --- a/src/test/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactoryTest.java +++ b/src/test/java/io/github/dsheirer/dsp/filter/decimate/DecimationFilterFactoryTest.java @@ -83,7 +83,25 @@ public class DecimationFilterFactoryTest assertEquals(2, DecimationFilterFactory.getDecimationRate(4)); assertEquals(4, DecimationFilterFactory.getDecimationRate(5)); assertEquals(64, DecimationFilterFactory.getDecimationRate(96)); - assertEquals(1024, DecimationFilterFactory.getDecimationRate(4096)); + assertEquals(1024, DecimationFilterFactory.getDecimationRate(2048)); + assertEquals(2048, DecimationFilterFactory.getDecimationRate(4096)); + assertEquals(4096, DecimationFilterFactory.getDecimationRate(4097)); + assertEquals(4096, DecimationFilterFactory.getDecimationRate(100000)); + } + + /** + * Test: each decimation rate reduces the sample count by that rate and rejects a length that isn't a multiple. + */ + @Test + void highestRatesDecimateAndValidateLength() + { + for(int decimation : new int[]{1024, 2048, 4096}) + { + assertEquals(1, DecimationFilterFactory.getRealDecimationFilter(decimation) + .decimateReal(new float[decimation]).length); + assertThrows(IllegalArgumentException.class, () -> DecimationFilterFactory + .getRealDecimationFilter(decimation).decimateReal(new float[decimation / 2])); + } } /** @@ -138,7 +156,7 @@ public class DecimationFilterFactoryTest @Test void aliasRejection() { - for(int decimation = 2; decimation <= 512; decimation *= 2) + for(int decimation = 2; decimation <= DecimationFilterFactory.MAXIMUM_DECIMATION_RATE; decimation *= 2) { double sampleRate = DECIMATED_SAMPLE_RATE * decimation; diff --git a/src/test/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerTest.java b/src/test/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerTest.java index 7086ddba..8d80a7ad 100644 --- a/src/test/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerTest.java +++ b/src/test/java/io/github/dsheirer/source/tuner/bladerf/BladeRfTunerTest.java @@ -20,11 +20,16 @@ package io.github.dsheirer.source.tuner.bladerf; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.dsheirer.bladerf.GainMode; +import io.github.dsheirer.bladerf.Version; +import io.github.dsheirer.buffer.SignedByteNativeBuffer; +import io.github.dsheirer.dsp.filter.channelizer.ComplexPolyphaseChannelizerM2; import io.github.dsheirer.source.tuner.TunerClass; import io.github.dsheirer.source.tuner.TunerType; import io.github.dsheirer.source.tuner.configuration.TunerConfiguration; @@ -64,15 +69,85 @@ class BladeRfTunerTest assertEquals(BladeRfSampleRate.RATE_1_25, BladeRfSampleRate.getClosest(rates, 1)); } + @Test + void sampleRatesFollowTheDeviceMaximum() + { + //bladeRF 2.0 on USB 3.0 with FPGA version 0.15.0 or later + assertEquals(List.of(BladeRfSampleRate.values()), BladeRfSampleRate.getRates(122_880_000)); + //bladeRF 2.0 on USB 3.0 with an older FPGA version + assertEquals(BladeRfSampleRate.RATE_61_44, BladeRfSampleRate.getRates(61_440_000).getLast()); + //bladeRF 1.0 on USB 3.0 + assertEquals(BladeRfSampleRate.RATE_40_0, BladeRfSampleRate.getRates(40_000_000).getLast()); + //USB 2.0 + assertEquals(BladeRfSampleRate.RATE_7_5, BladeRfSampleRate.getRates(8_000_000).getLast()); + } + + @Test + void savedRateAboveTheDeviceMaximumIsReduced() + { + List rates = BladeRfSampleRate.getRates(40_000_000); + + assertEquals(BladeRfSampleRate.RATE_40_0, BladeRfSampleRate.getClosest(rates, + BladeRfSampleRate.RATE_122_88.getRate())); + } + + @Test + void onlyRatesAbove61MhzAreOversampled() + { + assertFalse(BladeRfSampleRate.RATE_61_44.isOversampled()); + assertTrue(BladeRfSampleRate.RATE_70_0.isOversampled()); + assertTrue(BladeRfSampleRate.RATE_122_88.isOversampled()); + } + + @Test + void oversampledUsableBandwidthIsLimitedByTheAnalogFilter() + { + assertEquals(0.90, BladeRfTunerController.getOversampleUsableBandwidthPercentage(BladeRfSampleRate.RATE_70_0)); + assertEquals(66_000_000, BladeRfTunerController.getOversampleUsableBandwidthPercentage( + BladeRfSampleRate.RATE_122_88) * BladeRfSampleRate.RATE_122_88.getRate(), 1.0); + } + + @Test + void oversampleRequiresFpga15() + { + assertFalse(BladeRfTunerController.isOversampleSupported(new Version(0, 14, 0, "0.14.0"))); + assertTrue(BladeRfTunerController.isOversampleSupported(new Version(0, 15, 0, "0.15.0"))); + assertTrue(BladeRfTunerController.isOversampleSupported(new Version(0, 16, 3, "0.16.3"))); + assertTrue(BladeRfTunerController.isOversampleSupported(new Version(1, 0, 0, "1.0.0"))); + } + + @Test + void streamBufferFitsTheEightBitNativeBuffer() + { + int sampleCount = 65536; + + assertEquals(sampleCount, new SignedByteNativeBuffer(new byte[sampleCount * 2], 0, 0, 0, 1).sampleCount()); + } + @Test void sampleRatesDivideIntoPolyphaseChannels() { for(BladeRfSampleRate rate : BladeRfSampleRate.values()) { - assertEquals(0, rate.getRate() % 50_000, rate.toString()); + if(rate != BladeRfSampleRate.RATE_61_44 && rate != BladeRfSampleRate.RATE_122_88) + { + assertEquals(0, rate.getRate() % 50_000, rate.toString()); + } + + int channelCount = ComplexPolyphaseChannelizerM2.getChannelCount(rate.getRate()); + assertEquals(0, channelCount % 2, rate.toString()); + assertTrue((double)rate.getRate() / channelCount >= 25_000.0, rate.toString()); } } + @Test + void maximumUsbRateDoesNotOverflow() + { + assertEquals(1_966_080_000L, BladeRfSampleRate.RATE_61_44.getRate() * 32L); + assertTrue(BladeRfSampleRate.RATE_61_44.getRate() * 32L <= Integer.MAX_VALUE); + assertEquals(1_966_080_000L, BladeRfSampleRate.RATE_122_88.getRate() * 16L); + } + @Test void configurationSurvivesSerialization() throws Exception { diff --git a/src/test/java/io/github/dsheirer/source/tuner/channel/HalfBandTunerChannelSourceTest.java b/src/test/java/io/github/dsheirer/source/tuner/channel/HalfBandTunerChannelSourceTest.java index 71ba1ee0..599f781d 100644 --- a/src/test/java/io/github/dsheirer/source/tuner/channel/HalfBandTunerChannelSourceTest.java +++ b/src/test/java/io/github/dsheirer/source/tuner/channel/HalfBandTunerChannelSourceTest.java @@ -20,9 +20,18 @@ package io.github.dsheirer.source.tuner.channel; import io.github.dsheirer.buffer.FloatNativeBuffer; import io.github.dsheirer.buffer.INativeBuffer; +import io.github.dsheirer.buffer.SignedByteNativeBuffer; +import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory; +import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter; +import io.github.dsheirer.sample.complex.ComplexSamples; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; 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 heterodyne channel source applies frequency updates on the sample processing thread. @@ -76,6 +85,137 @@ public class HalfBandTunerChannelSourceTest assertEquals(50000.0, source.getSampleRate()); } + private static HalfBandTunerChannelSource source(double sampleRate) throws Exception + { + return new HalfBandTunerChannelSource<>(sourceEvent -> {}, new TunerChannel(CHANNEL_FREQUENCY, 12500), sampleRate, + new ChannelSpecification(25000.0, 12500, 6000.0, 7000.0), "test", null); + } + + /** + * Test: a decoder can apply more decimation at the highest source decimation rates, where a 2048 sample fragment + * produces 2 samples or less, since these are assembled into larger buffers for the decoder. + */ + @Test + void highDecimationProvidesBuffersThatDecodersCanDecimate() throws Exception + { + //Tuner sample rates that select a decimation of 1024, 2048 and 4096 + for(int[] rates : new int[][]{{30_000_000, 1024}, {60_000_000, 2048}, {120_000_000, 4096}}) + { + for(int decoderDecimation : new int[]{2, 4, 8, 16, 32, 64}) + { + HalfBandTunerChannelSource source = source(rates[0]); + IRealDecimationFilter filter = DecimationFilterFactory.getRealDecimationFilter(decoderDecimation); + List errors = new ArrayList<>(); + AtomicInteger sampleCount = new AtomicInteger(); + + source.setListener(samples -> + { + try + { + sampleCount.addAndGet(samples.i().length); + filter.decimateReal(samples.i()); + } + catch(Throwable t) + { + errors.add(t); + } + }); + + //Each native buffer has 65536 samples in 2048 sample fragments + int nativeBuffers = 64 * rates[1] / 65536; + + for(int x = 0; x < nativeBuffers; x++) + { + source.new NativeBufferProcessor().receive(new SignedByteNativeBuffer(new byte[131072], 0L, 0, 0, 1)); + } + + assertEquals(List.of(), errors); + assertEquals((double)rates[0] / rates[1], source.getSampleRate()); + assertEquals(64 * (nativeBuffers * 65536 / rates[1] / 64), sampleCount.get()); + } + } + } + + /** + * Test: assembled buffers preserve the timestamp of the first sample in each buffer. + */ + @Test + void assembledBuffersPreserveTimestamps() throws Exception + { + HalfBandTunerChannelSource source = source(120_000_000.0); + List buffers = new ArrayList<>(); + source.setListener(buffers::add); + + //Each native buffer has 32768 samples that produce 8 decimated samples, so 12 native buffers provide 96 samples + for(long timestamp = 1000; timestamp <= 12000; timestamp += 1000) + { + source.new NativeBufferProcessor().receive(new FloatNativeBuffer(new float[65536], timestamp, 0.0f)); + } + + assertEquals(1, buffers.size()); + assertEquals(64, buffers.getFirst().i().length); + assertEquals(64, buffers.getFirst().q().length); + assertEquals(1000L, buffers.getFirst().timestamp()); + } + + /** + * Test: assembling sample fragments doesn't change the samples, so the output matches decimating complete blocks, + * including when sample buffers end with a short fragment. + */ + @Test + void assembledInputMatchesContinuousDecimation() throws Exception + { + int decimation = 4096; + //Native buffers provide a 2048 sample fragment and a 1024 sample fragment, which straddle the filter blocks + int fragment = 3072; + int fragments = 800; + float[] interleaved = new float[fragment * fragments * 2]; + + for(int x = 0; x < fragment * fragments; x++) + { + double phase = 2.0 * Math.PI * 3000.0 * x / 120_000_000.0; + interleaved[x * 2] = (float)Math.cos(phase); + interleaved[x * 2 + 1] = (float)Math.sin(phase); + } + + HalfBandTunerChannelSource source = source(120_000_000.0); + List buffers = new ArrayList<>(); + source.setListener(buffers::add); + + for(int x = 0; x < fragments; x++) + { + float[] samples = Arrays.copyOfRange(interleaved, x * fragment * 2, (x + 1) * fragment * 2); + source.new NativeBufferProcessor().receive(new FloatNativeBuffer(samples, 0L, 0.0f)); + } + + IRealDecimationFilter expectedI = DecimationFilterFactory.getRealDecimationFilter(decimation); + float[] blockI = new float[decimation]; + List expected = new ArrayList<>(); + + for(int x = 0; x + decimation <= fragment * fragments; x += decimation) + { + for(int y = 0; y < decimation; y++) + { + blockI[y] = interleaved[(x + y) * 2]; + } + + expected.add(expectedI.decimateReal(blockI)[0]); + } + + int pointer = 0; + + for(ComplexSamples buffer : buffers) + { + for(float sample : buffer.i()) + { + assertEquals(expected.get(pointer++), sample, 1e-4f); + } + } + + assertEquals(expected.size() / 64 * 64, pointer); + assertTrue(pointer > 0); + } + /** * Test: concurrent tuner frequency and channel correction updates are both reflected in the mixer. */