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.
*/