#2533 Polyphase channelizer enhancements for correctness and efficiency. (#2534)

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-10-30 06:47:50 -04:00
committed by GitHub
parent 00c0418017
commit 9ebbf957a2
29 changed files with 2062 additions and 194 deletions

View File

@@ -0,0 +1,141 @@
/*
* *****************************************************************************
* 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.dsp.filter.channelizer;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.util.List;
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 the selection of one or two polyphase channel indexes for a tuner channel.
*/
public class ChannelCalculatorTest
{
private static final long CENTER = 100_000_000L;
private static final double SAMPLE_RATE = 2_400_000.0;
private static final int CHANNEL_COUNT = 96;
private static final int CHANNEL_BANDWIDTH = 25000;
private static List<Integer> indexes(int channelCenterOffset, int frequencyOffset, int bandwidth)
{
return calculator().getChannelIndexes(new TunerChannel(frequency(channelCenterOffset, frequencyOffset), bandwidth));
}
private static long frequency(int channelCenterOffset, int frequencyOffset)
{
return CENTER + (long)channelCenterOffset * CHANNEL_BANDWIDTH + frequencyOffset;
}
private static ChannelCalculator calculator()
{
return new ChannelCalculator(SAMPLE_RATE, CHANNEL_COUNT, CENTER, 2.0);
}
/**
* Test: a narrow channel stays on one index until its band edge reaches the channelizer roll-off.
*/
@Test
void narrowChannelMovesToTwoIndexesBeforeBandEdgeRollOff()
{
assertEquals(List.of(5), indexes(5, 2500, 12500));
assertEquals(List.of(5, 6), indexes(5, 3500, 12500));
assertEquals(List.of(4, 5), indexes(5, -3500, 12500));
assertEquals(List.of(5), indexes(5, 6000, 5000));
}
/**
* Test: a wide channel near a polyphase channel center stays on one index, otherwise it uses the nearest boundary.
*/
@Test
void wideChannelUsesOneIndexNearChannelCenter()
{
assertEquals(List.of(5), indexes(5, 0, 25000));
assertEquals(List.of(5), indexes(5, 1000, 25000));
assertEquals(List.of(5, 6), indexes(5, 3500, 25000));
assertEquals(List.of(4, 5), indexes(5, -3500, 25000));
assertEquals(List.of(5, 6), indexes(5, 12500, 25000));
}
/**
* Test: index pairs are ordered low to high in frequency across the center and wrap around indexes.
*/
@Test
void indexPairsAreOrderedAcrossCenterAndWrapAround()
{
assertEquals(List.of(95, 0), indexes(0, -6000, 12500));
assertEquals(List.of(0, 1), indexes(0, 6000, 12500));
assertEquals(List.of(47, 48), indexes(47, 12500, 12500));
assertEquals(List.of(48, 49), indexes(-47, -12500, 12500));
}
/**
* Test: every sourceable channel across the spectrum uses at most two adjacent indexes and the synthesized center
* frequency is the boundary nearest to the channel.
*/
@Test
void allChannelsUseAtMostTwoIndexesAroundNearestBoundary()
{
ChannelCalculator calculator = calculator();
for(int bandwidth : new int[]{3000, 6250, 12500, 15000, 25000})
{
long start = CENTER - (long)(SAMPLE_RATE / 2) + bandwidth;
long stop = CENTER + (long)(SAMPLE_RATE / 2) - bandwidth;
for(long frequency = start; frequency <= stop; frequency += 730)
{
TunerChannel channel = new TunerChannel(frequency, bandwidth);
List<Integer> indexes;
try
{
indexes = calculator.getChannelIndexes(channel);
}
catch(IllegalArgumentException iae)
{
continue; //Within the wrap around channel, which can't be sourced
}
assertTrue(indexes.size() <= 2, "Too many indexes " + indexes + " for " + channel);
long error = Math.abs(calculator.getCenterFrequencyForIndexes(indexes) - frequency);
assertTrue(error <= CHANNEL_BANDWIDTH / 2 + 1, "Center " + error + " Hz from " + channel);
if(indexes.size() == 2)
{
assertEquals((indexes.get(0) + 1) % CHANNEL_COUNT, indexes.get(1), "Not adjacent: " + indexes);
}
}
}
}
/**
* Test: channels outside of the tuner bandwidth are still rejected.
*/
@Test
void channelBeyondTunerBandwidthIsRejected()
{
assertThrows(IllegalArgumentException.class, () -> calculator().getChannelIndexes(
new TunerChannel(CENTER + (long)(SAMPLE_RATE / 2), 12500)));
}
}

View File

@@ -0,0 +1,289 @@
/*
* *****************************************************************************
* 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.dsp.filter.channelizer;
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;
import org.jtransforms.fft.FloatFFT_1D;
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.assertTrue;
/**
* Verifies that the channel DFT produces the same channel values as the IFFT, both directly and through the threaded
* channelizer, including across channel changes.
*/
public class ChannelDFTTest
{
private static final float TOLERANCE = 0.00001f;
private static final int BLOCKS_PER_BATCH = 1024;
private static final int TAPS_PER_CHANNEL = 9;
private static final int[] CHANNEL_COUNTS = {2, 8, 72, 96, 100, 128, 400};
private static float[] randomSamples(int length, Random random)
{
float[] samples = new float[length];
for(int x = 0; x < samples.length; x++)
{
samples[x] = random.nextFloat() * 2.0f - 1.0f;
}
return samples;
}
private static void assertMatchesIFFT(ChannelDFT channelDFT, int channelCount, int[] channels, Random random)
{
int blocks = 3;
int blockLength = channelCount * 2;
float[] input = randomSamples(blocks * blockLength, random);
float[] expected = input.clone();
FloatFFT_1D fft = new FloatFFT_1D(channelCount);
for(int block = 0; block < blocks; block++)
{
fft.complexInverse(expected, block * blockLength, true);
}
float[] actual = input.clone();
channelDFT.process(actual, channels);
for(int block = 0; block < blocks; block++)
{
for(int channel : channels)
{
int index = block * blockLength + channel * 2;
assertEquals(expected[index], actual[index], TOLERANCE, "I channel " + channel + " of " + channelCount);
assertEquals(expected[index + 1], actual[index + 1], TOLERANCE, "Q channel " + channel + " of " + channelCount);
}
}
}
/**
* Test: both implementations match the IFFT for each single channel and for sets of channels.
*/
@Test
void implementationsMatchIFFT()
{
Random random = new Random(0xC0FFEEL);
for(int channelCount : CHANNEL_COUNTS)
{
for(ChannelDFT channelDFT : List.of(new ScalarChannelDFT(channelCount), new VectorChannelDFT(channelCount)))
{
for(int channel = 0; channel < channelCount; channel++)
{
assertMatchesIFFT(channelDFT, channelCount, new int[]{channel}, random);
}
//All channels at once verifies that results don't overwrite the input of the other channels
int[] all = new int[channelCount];
Arrays.setAll(all, x -> x);
assertMatchesIFFT(channelDFT, channelCount, all, random);
}
}
}
/**
* Test: each implementation limits use to channel counts that it can process faster than the IFFT.
*/
@Test
void maximumChannelsAreAboveZero()
{
assertTrue(new ScalarChannelDFT(96).getMaximumChannels() > 0);
assertTrue(new VectorChannelDFT(96).getMaximumChannels() > 0);
}
/**
* Test: channels in use receive the same values as the IFFT through the threaded channelizer, on one index and on
* two adjacent indexes. Channelizer outputs include the alternating block phase handling, so this also verifies
* that both transforms see the same block order.
*/
@Test
void channelizerMatchesIFFTForChannelsInUse() throws Exception
{
int channelCount = 96;
float[] taps = taps(channelCount);
Random random = new Random(1234);
for(long frequency : new long[]{100_125_000L, 100_137_500L})
{
ChannelCalculator calculator = new ChannelCalculator(2_400_000.0, channelCount, 100_000_000L, 2.0);
List<float[]> sparse = new CopyOnWriteArrayList<>();
List<float[]> full = new CopyOnWriteArrayList<>();
ComplexPolyphaseChannelizerM2 sparseChannelizer = channelizer(taps, channelCount);
ComplexPolyphaseChannelizerM2 fullChannelizer = channelizer(taps, channelCount);
CapturingSource source = new CapturingSource(frequency, calculator, sparse);
sparseChannelizer.addChannel(source);
fullChannelizer.addChannel(new CapturingSource(frequency, calculator, full, channelCount));
sparseChannelizer.start();
fullChannelizer.start();
float[] samples = randomSamples(BLOCKS_PER_BATCH * channelCount * 3, random);
sparseChannelizer.receive(new InterleavedComplexSamples(samples, 0));
fullChannelizer.receive(new InterleavedComplexSamples(samples, 0));
await(sparse, 3);
await(full, 3);
sparseChannelizer.stop();
fullChannelizer.stop();
assertChannelsMatch(sparse, full, source.getOutputProcessorIndexes(), channelCount);
}
}
/**
* Test: when an update moves a channel to a different index, the first batch after the update has valid results for
* the new index.
*/
@Test
void channelUpdateAppliesBeforeTransform() throws Exception
{
int channelCount = 96;
float[] taps = taps(channelCount);
long frequency = 100_125_000L;
List<float[]> sparse = new CopyOnWriteArrayList<>();
List<float[]> full = new CopyOnWriteArrayList<>();
ComplexPolyphaseChannelizerM2 sparseChannelizer = channelizer(taps, channelCount);
ComplexPolyphaseChannelizerM2 fullChannelizer = channelizer(taps, channelCount);
ChannelCalculator calculator = new ChannelCalculator(2_400_000.0, channelCount, 100_000_000L, 2.0);
CapturingSource source = new CapturingSource(frequency, calculator, sparse);
sparseChannelizer.addChannel(source);
fullChannelizer.addChannel(new CapturingSource(frequency, calculator, full, channelCount));
sparseChannelizer.start();
fullChannelizer.start();
ChannelCalculator retuned = new ChannelCalculator(2_400_000.0, channelCount, 100_050_000L, 2.0);
source.updateOutputProcessor(retuned, new SynthesisFilterManager());
float[] samples = randomSamples(BLOCKS_PER_BATCH * channelCount, new Random(5678));
sparseChannelizer.receive(new InterleavedComplexSamples(samples, 0));
fullChannelizer.receive(new InterleavedComplexSamples(samples, 0));
await(sparse, 1);
await(full, 1);
sparseChannelizer.stop();
fullChannelizer.stop();
assertEquals(List.of(3), source.getOutputProcessorIndexes());
assertChannelsMatch(sparse, full, source.getOutputProcessorIndexes(), channelCount);
}
private static void assertChannelsMatch(List<float[]> actual, List<float[]> expected, List<Integer> indexes, int channelCount)
{
int blockLength = channelCount * 2;
for(int batch = 0; batch < expected.size(); batch++)
{
for(int index : indexes)
{
float[] expectedChannel = extract(expected.get(batch), index, blockLength);
float[] actualChannel = extract(actual.get(batch), index, blockLength);
assertArrayEquals(expectedChannel, actualChannel, TOLERANCE, "Channel " + index + " batch " + batch);
}
}
}
private static float[] extract(float[] samples, int index, int blockLength)
{
float[] channel = new float[samples.length / blockLength * 2];
for(int block = 0; block < channel.length / 2; block++)
{
channel[block * 2] = samples[block * blockLength + index * 2];
channel[block * 2 + 1] = samples[block * blockLength + index * 2 + 1];
}
return channel;
}
private static float[] taps(int channelCount)
{
float[] taps = randomSamples(channelCount * TAPS_PER_CHANNEL, new Random(99));
for(int x = 0; x < taps.length; x++)
{
taps[x] /= channelCount;
}
return taps;
}
private static ComplexPolyphaseChannelizerM2 channelizer(float[] taps, int channelCount)
{
return new ComplexPolyphaseChannelizerM2(taps, 2_400_000, channelCount);
}
private static void await(List<float[]> list, int size) throws Exception
{
long timeout = System.currentTimeMillis() + 5000;
while(list.size() < size && System.currentTimeMillis() < timeout)
{
Thread.sleep(2);
}
assertEquals(size, list.size(), "Batches received");
}
/**
* Channel source that captures a copy of each batch. A source can also claim more indexes than its channel, which
* forces the channelizer to use the IFFT.
*/
private static class CapturingSource extends PolyphaseChannelSource
{
private final List<float[]> mCaptured;
private final List<Integer> mForcedIndexes;
CapturingSource(long frequency, ChannelCalculator calculator, List<float[]> captured)
{
this(frequency, calculator, captured, 0);
}
CapturingSource(long frequency, ChannelCalculator calculator, List<float[]> captured, int forcedIndexCount)
{
super(new TunerChannel(frequency, 12500), calculator, new SynthesisFilterManager(), sourceEvent -> {},
"test", null);
mCaptured = captured;
mForcedIndexes = new ArrayList<>();
for(int x = 0; x < forcedIndexCount; x++)
{
mForcedIndexes.add(x);
}
}
@Override
public List<Integer> getOutputProcessorIndexes()
{
return mForcedIndexes.isEmpty() ? super.getOutputProcessorIndexes() : mForcedIndexes;
}
@Override
public void receiveChannelResults(ChannelResultsBatch channelResults)
{
mCaptured.add(channelResults.getSamples().clone());
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* *****************************************************************************
* 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.dsp.filter.channelizer;
import io.github.dsheirer.dsp.filter.channelizer.output.OneChannelOutputProcessor;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Queue;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the shared ownership of channel results batch sample arrays.
*/
public class ChannelResultsBatchTest
{
/**
* Test: the sample array only returns to the pool after every reference is released.
*/
@Test
void samplesReturnToPoolAfterLastRelease()
{
Queue<float[]> pool = new ArrayDeque<>();
float[] samples = new float[8];
ChannelResultsBatch batch = new ChannelResultsBatch(samples, 4, 0, pool);
batch.retain();
batch.retain();
batch.release();
batch.release();
assertTrue(pool.isEmpty(), "Returned to pool with an outstanding reference");
batch.release();
assertSame(samples, pool.poll());
}
/**
* Test: blocks are derived from the array and block lengths.
*/
@Test
void blockCountIsDerivedFromLengths()
{
assertEquals(2, new ChannelResultsBatch(new float[8], 4, 0, new ArrayDeque<>()).getBlockCount());
}
/**
* Test: an output processor that doesn't accept a batch because it isn't running doesn't keep a reference.
*/
@Test
void stoppedOutputProcessorReleasesBatch()
{
Queue<float[]> pool = new ArrayDeque<>();
ChannelResultsBatch batch = new ChannelResultsBatch(new float[8], 4, 0, pool);
OneChannelOutputProcessor processor = new OneChannelOutputProcessor(50000.0, List.of(0), 1.0f, null, "test");
processor.receiveChannelResults(batch);
batch.release();
assertEquals(1, pool.size());
}
}

View File

@@ -0,0 +1,124 @@
/*
* *****************************************************************************
* 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.dsp.filter.channelizer;
import io.github.dsheirer.dsp.filter.channelizer.output.OneChannelOutputProcessor;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Verifies that channel results batches keep their own timestamp and configuration through the threaded dispatchers.
*/
public class ChannelResultsTimestampTest
{
private static final int BATCH_SIZE = 2048;
private static final int CHANNEL_COUNT = 8;
private static final int SAMPLES_PER_BATCH = 1024 * CHANNEL_COUNT / 2;
private static void awaitSize(List<?> list, int size) throws Exception
{
long timeout = System.currentTimeMillis() + 5000;
while(list.size() < size && System.currentTimeMillis() < timeout)
{
Thread.sleep(5);
}
}
private static ChannelResultsBatch batch(float i, float q, long timestamp)
{
float[] samples = new float[BATCH_SIZE * 4];
for(int x = 0; x < samples.length; x += 4)
{
samples[x] = i;
samples[x + 1] = q;
samples[x + 2] = -i;
samples[x + 3] = -q;
}
return new ChannelResultsBatch(samples, 4, timestamp, new ArrayDeque<>());
}
/**
* Test: each batch dispatched by the channelizer has the timestamp of the sample buffer that completed the batch,
* even when a later sample buffer arrives before the batch is dispatched.
*/
@Test
void channelizerDispatchesEachBatchWithItsOwnTimestamp() throws Exception
{
List<Long> timestamps = new CopyOnWriteArrayList<>();
ComplexPolyphaseChannelizerM2 channelizer = new ComplexPolyphaseChannelizerM2(new float[CHANNEL_COUNT * 9],
25000 * CHANNEL_COUNT, CHANNEL_COUNT)
{
@Override
protected void dispatch(ChannelResultsBatch channelResults)
{
timestamps.add(channelResults.getTimestamp());
}
};
channelizer.start();
for(long timestamp : new long[]{10, 20, 30})
{
channelizer.receive(new InterleavedComplexSamples(new float[SAMPLES_PER_BATCH * 2], timestamp));
}
awaitSize(timestamps, 3);
channelizer.stop();
assertEquals(List.of(10L, 20L, 30L), timestamps);
}
/**
* Test: assembled sample buffers have the timestamp of their batch, and a configuration change requested between
* two batches takes effect at the start of the second batch, even when the first batch hasn't been processed yet.
*/
@Test
void outputProcessorAppliesTimestampAndConfigurationPerBatch() throws Exception
{
List<ComplexSamples> output = new CopyOnWriteArrayList<>();
OneChannelOutputProcessor processor = new OneChannelOutputProcessor(50000.0, List.of(0), 1.0f, null, "test");
processor.setListener(output::add);
processor.start();
processor.receiveChannelResults(batch(1.0f, 2.0f, 100));
processor.setPolyphaseChannelIndices(List.of(1));
processor.receiveChannelResults(batch(1.0f, 2.0f, 200));
awaitSize(output, 2);
processor.stop();
float[] expected = new float[BATCH_SIZE];
assertEquals(100, output.get(0).timestamp());
Arrays.fill(expected, 1.0f);
assertArrayEquals(expected, output.get(0).i());
assertEquals(200, output.get(1).timestamp());
Arrays.fill(expected, -1.0f);
assertArrayEquals(expected, output.get(1).i());
}
}

View File

@@ -19,8 +19,12 @@
package io.github.dsheirer.dsp.filter.channelizer;
import io.github.dsheirer.sample.complex.InterleavedComplexSamples;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;
import org.jtransforms.fft.FloatFFT_1D;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -86,6 +90,94 @@ public class ComplexPolyphaseChannelizerM2Test
}
}
/**
* Test: the channelizer's output is bit-for-bit identical to a reference that shifts all of its samples for each
* block, across several wraps of the sample buffer window and with buffers that end part way through a block.
*/
@Test
void slidingWindowMatchesShiftingReference() throws Exception
{
Random rng = new Random(0xC0FFEEL);
for(int channelCount : new int[]{2, 8, 96, 100, 400})
{
Harness harness = new Harness(channelCount, TAPS_PER_CHANNEL, rng);
List<float[]> batches = new CopyOnWriteArrayList<>();
ComplexPolyphaseChannelizerM2 channelizer = new ComplexPolyphaseChannelizerM2(harness.getCoefficients(),
25000 * channelCount, channelCount)
{
@Override
protected void dispatch(ChannelResultsBatch channelResults)
{
batches.add(channelResults.getSamples().clone());
}
};
int batchCount = 3;
int blocksPerBatch = 1024;
float[] samples = new float[batchCount * blocksPerBatch * channelCount];
for(int x = 0; x < samples.length; x++)
{
samples[x] = rng.nextFloat() * 2f - 1f;
}
channelizer.start();
int pointer = 0;
while(pointer < samples.length)
{
int length = Math.min(2 * (1 + rng.nextInt(channelCount * 3)), samples.length - pointer);
channelizer.receive(new InterleavedComplexSamples(Arrays.copyOfRange(samples, pointer, pointer + length), 0));
pointer += length;
}
long timeout = System.currentTimeMillis() + 5000;
while(batches.size() < batchCount && System.currentTimeMillis() < timeout)
{
Thread.sleep(2);
}
channelizer.stop();
assertEquals(batchCount, batches.size(), "Batches (channelCount=" + channelCount + ")");
FloatFFT_1D fft = new FloatFFT_1D(channelCount);
int blockLength = channelCount * 2;
boolean topBlock = TAPS_PER_CHANNEL % 2 == 0;
for(int block = 0; block < batchCount * blocksPerBatch; block++)
{
harness.load(samples, block * channelCount, channelCount);
float[] expected = harness.processFused(topBlock);
topBlock = !topBlock;
harness.shift(channelCount);
fft.complexInverse(expected, true);
float[] batch = batches.get(block / blocksPerBatch);
int offset = (block % blocksPerBatch) * blockLength;
assertBitIdentical(expected, Arrays.copyOfRange(batch, offset, offset + blockLength), channelCount, 0, block);
}
}
}
/**
* Test: channel counts are even, have only factors of 2, 3 and 5, and are as large as possible for the minimum
* channel bandwidth.
*/
@Test
void channelCountIsAnEfficientIFFTSize()
{
assertEquals(96, ComplexPolyphaseChannelizerM2.getChannelCount(2_400_000.0));
assertEquals(128, ComplexPolyphaseChannelizerM2.getChannelCount(3_200_000.0));
assertEquals(400, ComplexPolyphaseChannelizerM2.getChannelCount(10_000_000.0));
assertEquals(72, ComplexPolyphaseChannelizerM2.getChannelCount(1_920_000.0));
assertEquals(100, ComplexPolyphaseChannelizerM2.getChannelCount(2_560_000.0));
assertEquals(108, ComplexPolyphaseChannelizerM2.getChannelCount(2_880_000.0));
assertEquals(240, ComplexPolyphaseChannelizerM2.getChannelCount(6_100_000.0));
assertEquals(2, ComplexPolyphaseChannelizerM2.getChannelCount(50_000.0));
}
private static void assertBitIdentical(float[] reference, float[] fused, int channelCount, int mode, int iteration)
{
assertEquals(reference.length, fused.length,
@@ -116,6 +208,7 @@ public class ComplexPolyphaseChannelizerM2Test
private final int[] mTopBlockMap; // length mSubChannelCount
private final int[] mMiddleBlockMap; // length mSubChannelCount
private final float[] mReusableAccumulator;
private final float[] mCoefficients;
private Harness(int channelCount, int tapsPerChannel, Random rng)
{
@@ -129,6 +222,7 @@ public class ComplexPolyphaseChannelizerM2Test
coefficients[i] = rng.nextFloat() * 2f - 1f;
}
mCoefficients = coefficients;
mInlineFilter = getAlignedFilter(coefficients, channelCount, tapsPerChannel);
mInlineSamples = new float[mBufferLength];
mTopBlockMap = getTopBlockMap(channelCount);
@@ -136,6 +230,23 @@ public class ComplexPolyphaseChannelizerM2Test
mReusableAccumulator = new float[mSubChannelCount];
}
private float[] getCoefficients()
{
return mCoefficients;
}
//Loads a block of samples to the start of the sample buffer
private void load(float[] samples, int offset, int blockLength)
{
System.arraycopy(samples, offset, mInlineSamples, 0, blockLength);
}
//Shifts all of the samples by one block
private void shift(int blockLength)
{
System.arraycopy(mInlineSamples, 0, mInlineSamples, blockLength, mBufferLength - blockLength);
}
//Reference: multiply the whole array into an interim buffer, then accumulate across taps (prior behavior).
private float[] processReference(boolean topBlock)
{

View File

@@ -0,0 +1,139 @@
/*
* *****************************************************************************
* 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.dsp.filter.channelizer;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.util.ArrayDeque;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
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 a failure in one polyphase channel source is contained to that source and is reported to its consumer.
*/
public class PolyphaseChannelSourceTest
{
private static final long CENTER = 100_000_000L;
private static final double SAMPLE_RATE = 2_400_000.0;
private static final int CHANNEL_COUNT = 96;
private static ChannelCalculator calculator(long center)
{
return new ChannelCalculator(SAMPLE_RATE, CHANNEL_COUNT, center, 2.0);
}
private static ChannelResultsBatch batch()
{
return new ChannelResultsBatch(new float[CHANNEL_COUNT * 2], CHANNEL_COUNT * 2, 0, new ArrayDeque<>());
}
private static PolyphaseChannelSource source()
{
return new PolyphaseChannelSource(new TunerChannel(CENTER + 125000, 12500), calculator(CENTER),
new SynthesisFilterManager(), sourceEvent -> {}, "test", null);
}
/**
* Test: an output processor update failure doesn't throw to the channelizer, signals one error to the consumer and
* stops this source from processing further results.
*/
@Test
void failedOutputProcessorUpdateSignalsErrorOnce() throws Exception
{
CountDownLatch errorLatch = new CountDownLatch(1);
AtomicInteger errorCount = new AtomicInteger();
Listener<SourceEvent> consumer = sourceEvent -> {
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_ERROR_STATE)
{
errorCount.incrementAndGet();
errorLatch.countDown();
}
};
PolyphaseChannelSource source = source();
source.setSourceEventListener(consumer);
//Retune so that the channel is outside of the tuner bandwidth
source.updateOutputProcessor(calculator(CENTER + 10_000_000L), new SynthesisFilterManager());
source.applyPendingOutputProcessorUpdate();
source.receiveChannelResults(batch());
source.applyPendingOutputProcessorUpdate();
source.receiveChannelResults(batch());
assertTrue(errorLatch.await(5, TimeUnit.SECONDS), "Error was not signalled to the consumer");
Thread.sleep(50);
assertEquals(1, errorCount.get());
}
/**
* Test: an exception from one channel doesn't stop delivery of the results to the following channels.
*/
@Test
void channelFailureDoesNotAffectFollowingChannels()
{
AtomicInteger received = new AtomicInteger();
PolyphaseChannelSource failing = new TestSource()
{
@Override
public void receiveChannelResults(ChannelResultsBatch channelResults)
{
throw new IllegalStateException("Test failure");
}
};
PolyphaseChannelSource following = new TestSource()
{
@Override
public void receiveChannelResults(ChannelResultsBatch channelResults)
{
received.incrementAndGet();
}
};
ComplexPolyphaseChannelizerM2 channelizer = new ComplexPolyphaseChannelizerM2(new float[CHANNEL_COUNT * 9],
(int)SAMPLE_RATE, CHANNEL_COUNT);
channelizer.addChannel(failing);
channelizer.addChannel(following);
channelizer.prepareChannels();
channelizer.dispatch(batch());
assertEquals(1, received.get());
}
/**
* Polyphase channel source for overriding the channel results processing.
*/
private static class TestSource extends PolyphaseChannelSource
{
TestSource()
{
super(new TunerChannel(CENTER + 125000, 12500), calculator(CENTER), new SynthesisFilterManager(),
sourceEvent -> {}, "test", null);
}
}
}

View File

@@ -48,7 +48,9 @@ public class TwoChannelSynthesizerM2Test
/**
* Test: tones at mirrored offsets from the boundary between two channels produce equal output levels, and a tone
* exactly on the boundary produces the same level as one near the center of the composite.
* exactly on the boundary produces the same level as one near the center of the composite. The pass band must
* extend past the center of each channel so that a signal offset from the boundary doesn't lose its outer band edge,
* and tones beyond the alias-free region of the composite must be rejected.
*/
@Test
void boundaryResponseIsFlatAndSymmetric() throws Exception
@@ -58,19 +60,17 @@ public class TwoChannelSynthesizerM2Test
double center = gain(0.0, tapsPerChannel);
assertEquals(1.0, center, 0.01, "Unity gain at channel boundary (taps per channel=" + tapsPerChannel + ")");
for(double offset : new double[]{0.1, 0.2, 0.3, 0.4})
for(double offset : new double[]{0.1, 0.2, 0.3, 0.4, 0.5, 0.6})
{
double below = gain(-offset, tapsPerChannel);
double above = gain(offset, tapsPerChannel);
assertEquals(below, above, 0.01, "Asymmetric response at +/-" + offset +
" channel bandwidths from boundary (taps per channel=" + tapsPerChannel + ")");
//Synthesis filter cutoff is 0.55 channel bandwidths, so only check flatness for the inner region
if(offset <= 0.25)
{
assertTrue(below > 0.98, "Passband droop at " + offset + " channel bandwidths: " + below);
}
assertTrue(below > 0.98, "Passband droop at " + offset + " channel bandwidths: " + below);
}
double stopBand = gain(0.9, tapsPerChannel);
assertTrue(stopBand < 0.001, "Stop band leakage at 0.9 channel bandwidths: " + stopBand);
}
}
@@ -88,9 +88,15 @@ public class TwoChannelSynthesizerM2Test
CHANNEL_COUNT)
{
@Override
protected void dispatch(List<float[]> channelResultsList)
protected void dispatch(ChannelResultsBatch channelResults)
{
results.addAll(channelResultsList);
float[] samples = channelResults.getSamples();
int blockLength = channelResults.getBlockLength();
for(int offset = 0; offset < samples.length; offset += blockLength)
{
results.add(Arrays.copyOfRange(samples, offset, offset + blockLength));
}
}
};
@@ -126,7 +132,7 @@ public class TwoChannelSynthesizerM2Test
q2[x] = sign * result[2 * (LOWER_CHANNEL + 1) + 1];
}
float[] filter = FilterFactory.getSincM2Synthesizer(2.0 * CHANNEL_BANDWIDTH, CHANNEL_BANDWIDTH, 2, 9);
float[] filter = new SynthesisFilterManager().getFilter(2.0 * CHANNEL_BANDWIDTH, CHANNEL_BANDWIDTH, 2);
ComplexSamples output = new TwoChannelSynthesizerM2(filter).process(new ComplexSamples(i1, q1, 0),
new ComplexSamples(i2, q2, 0));

View File

@@ -0,0 +1,128 @@
/*
* *****************************************************************************
* 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.util;
import java.util.List;
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.assertTrue;
/**
* Verifies dispatcher queue limits.
*/
public class DispatcherTest
{
private static final int MAXIMUM_QUEUE_SIZE = 4;
/**
* Sends the count of elements while the dispatcher's listener is blocked on the first element.
* @return elements received by the listener after it is unblocked
*/
private static List<Integer> dispatchWhileBlocked(Dispatcher<Integer> dispatcher, int count) throws Exception
{
List<Integer> received = new CopyOnWriteArrayList<>();
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
dispatcher.setListener(element -> {
received.add(element);
blocked.countDown();
try
{
release.await();
}
catch(InterruptedException ie)
{
Thread.currentThread().interrupt();
}
});
dispatcher.start();
dispatcher.receive(0);
assertTrue(blocked.await(5, TimeUnit.SECONDS), "Listener did not receive the first element");
for(int x = 1; x <= count; x++)
{
dispatcher.receive(x);
}
release.countDown();
return received;
}
private static void awaitSize(List<Integer> list, int size) throws Exception
{
long timeout = System.currentTimeMillis() + 5000;
while(list.size() < size && System.currentTimeMillis() < timeout)
{
Thread.sleep(5);
}
}
/**
* Test: elements received while a bounded queue is full are dropped and the queued elements stay in order.
*/
@Test
void boundedQueueDropsNewestElementsWhenFull() throws Exception
{
Dispatcher<Integer> dispatcher = new Dispatcher<>("test", 1, MAXIMUM_QUEUE_SIZE);
List<Integer> received = dispatchWhileBlocked(dispatcher, MAXIMUM_QUEUE_SIZE + 10);
awaitSize(received, MAXIMUM_QUEUE_SIZE + 1);
Thread.sleep(50);
dispatcher.stop();
assertEquals(List.of(0, 1, 2, 3, 4), received);
}
/**
* Test: a bounded dispatcher resumes delivery after an overflow.
*/
@Test
void boundedQueueRecoversAfterOverflow() throws Exception
{
Dispatcher<Integer> dispatcher = new Dispatcher<>("test", 1, MAXIMUM_QUEUE_SIZE);
List<Integer> received = dispatchWhileBlocked(dispatcher, MAXIMUM_QUEUE_SIZE + 10);
awaitSize(received, MAXIMUM_QUEUE_SIZE + 1);
dispatcher.receive(99);
awaitSize(received, MAXIMUM_QUEUE_SIZE + 2);
dispatcher.stop();
assertEquals(99, received.get(received.size() - 1));
}
/**
* Test: an unbounded dispatcher doesn't drop elements.
*/
@Test
void unboundedQueueKeepsAllElements() throws Exception
{
Dispatcher<Integer> dispatcher = new Dispatcher<>("test", 1);
List<Integer> received = dispatchWhileBlocked(dispatcher, 100);
awaitSize(received, 101);
dispatcher.stop();
assertEquals(101, received.size());
}
}