#736 New JavaFX spectral display and waterfall for both tuner and channel spectrums. (#2637)

* Popout configuration panel (top-left of display)
* Themed color support
* Reworked signal averating/smoothing
* Optional decibel overlay with corrected decibel calculations
* Integrated noise floor slider in  channel view
* Traffic channel overlays
* Popup channel information when hovering over playlist channels.
* Removes legacy swing components.

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-11-13 07:30:31 -05:00
committed by GitHub
parent 73fc85aa76
commit 9641d2ef59
53 changed files with 7121 additions and 3644 deletions

View File

@@ -0,0 +1,140 @@
/*
* *****************************************************************************
* 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.gui.spectrum;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectral display frequency, bin and screen position conversions.
*/
public class SpectralDisplayModelTest
{
private static SpectralDisplayModel model(long center, int sampleRate, int bins)
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.centerFrequencyProperty().set(center);
model.sampleRateProperty().set(sampleRate);
model.setBinCount(bins);
return model;
}
@Test
void centerBinIsAtCenterFrequency()
{
SpectralDisplayModel model = model(100_000_000, 2_048_000, 4096);
double width = 1024;
//Bin 2048 (the DC bin) is centered at x = (2048 + 0.5) * pixels per bin
double x = (2048 + 0.5) * model.getPixelsPerBin(width);
assertEquals(100_000_000, model.getFrequency(x, width), 1);
assertEquals(x, model.getX(100_000_000, width), 1E-6);
}
@Test
void frequencyAndPositionRoundTrip()
{
SpectralDisplayModel model = model(851_000_000, 10_000_000, 8192);
double width = 1500;
for(int zoom = 0; zoom <= SpectralDisplayModel.MAX_ZOOM; zoom++)
{
model.setZoom(zoom, 852_000_000, 0.25);
for(double x = 0; x <= width; x += 37.5)
{
long frequency = model.getFrequency(x, width);
assertEquals(x, model.getX(frequency, width), 0.01, "zoom " + zoom);
}
}
}
@Test
void zoomKeepsFrequencyUnderMouse()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
double width = 1200;
double x = 900;
long frequency = model.getFrequency(x, width);
model.setZoom(3, frequency, x / width);
assertEquals(8, model.getZoomMultiplier());
assertEquals(4096 / 8, model.getVisibleBinCount());
//Within one bin width of the original frequency
assertEquals(frequency, model.getFrequency(x, width), model.getBinWidth());
}
@Test
void zoomOffsetIsLimitedToTheBins()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(2, model.getMaxFrequency(), 0.0);
assertEquals(4096 - 1024, model.getZoomOffset(), 1E-9);
model.setZoomOffset(-50);
assertEquals(0, model.getZoomOffset(), 1E-9);
model.setZoom(0, model.getCenterFrequency(), 0.5);
assertEquals(0, model.getZoomOffset(), 1E-9);
}
@Test
void zoomIsLimited()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(99, model.getCenterFrequency(), 0.5);
assertEquals(SpectralDisplayModel.MAX_ZOOM, model.getZoom());
model.setZoom(-5, model.getCenterFrequency(), 0.5);
assertEquals(SpectralDisplayModel.MIN_ZOOM, model.getZoom());
}
@Test
void binCountChangeKeepsLeftEdgeFrequency()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(2, 460_300_000, 0.0);
double left = model.getMinFrequency() + model.getZoomOffset() * model.getBinWidth();
model.setBinCount(16384);
double after = model.getMinFrequency() + model.getZoomOffset() * model.getBinWidth();
assertEquals(left, after, 1.0);
assertTrue(model.getZoomOffset() <= model.getBinCount() - model.getVisibleBinCount());
}
@Test
void dynamicRangeIncludesProcessingGain()
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.sampleSizeProperty().set(12.0);
model.setBinCount(4096);
model.noiseBandwidthProperty().set(2.0);
//66.23 dB sample range + 10 * log10(4096 / 2) = 33.11 dB processing gain
assertEquals(99.34, model.getDynamicRange(), 0.01);
model.setBinCount(8192);
assertEquals(102.35, model.getDynamicRange(), 0.01);
}
@Test
void dynamicRangeFollowsSampleSize()
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.sampleSizeProperty().set(12.0);
assertEquals(66.22, model.getDynamicRange(), 0.01);
}
}

View File

@@ -0,0 +1,72 @@
/*
* *****************************************************************************
* 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.gui.spectrum;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectral display themes and waterfall palettes.
*/
public class SpectralDisplayThemeTest
{
@Test
void gradientEndsAtTheFirstAndLastColors()
{
int[] palette = WaterfallPalette.gradient("#000000", "#FF8000", "#FFFFFF");
assertEquals(WaterfallPalette.SIZE, palette.length);
assertEquals(0xFF000000, palette[0]);
assertEquals(0xFFFFFFFF, palette[WaterfallPalette.SIZE - 1]);
}
@Test
void classicPaletteMatchesTheOriginalWaterfall()
{
int[] palette = WaterfallPalette.classic();
assertEquals(0xFF00007F, palette[0]);
assertEquals(0xFF0000BF, palette[16]);
assertEquals(0xFFFFFF00, palette[60]);
assertEquals(0xFFFF0000, palette[255]);
}
@Test
void everyThemeHasAnOpaqueWaterfallAndALabel()
{
for(SpectralDisplayTheme theme: SpectralDisplayTheme.values())
{
assertTrue(theme.getLabel() != null && !theme.getLabel().isEmpty(), theme.name());
assertEquals(1.0, theme.getBackground().getOpacity(), 1E-9, theme.name());
for(int index = 0; index < WaterfallPalette.SIZE; index++)
{
assertEquals(0xFF, theme.getWaterfallColor(index) >>> 24, theme.name() + " index " + index);
}
}
}
@Test
void negativeByteIndexesMapToTheUpperPalette()
{
SpectralDisplayTheme theme = SpectralDisplayTheme.CLASSIC;
assertEquals(theme.getWaterfallColor(255), theme.getWaterfallColor((byte)255));
}
}

View File

@@ -0,0 +1,147 @@
/*
* *****************************************************************************
* 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.gui.spectrum;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectrum attack/decay averaging and peak hold.
*/
public class SpectrumAveragerTest
{
/**
* Noise power in a DFT bin is exponentially distributed. Averaging its dB values reads about 2.5 dB below the
* mean power. Averaging linear power (symmetric rates) should read the mean power.
*/
@Test
void linearPowerAveragingMeasuresMeanNoisePower()
{
Random random = new Random(3);
int bins = 2048;
double meanPower = 1E-6; //-60 dB
double linearSum = 0.0;
double decibelSum = 0.0;
int count = 0;
for(int frame = 0; frame < 4000; frame++)
{
for(int x = 0; x < bins; x++)
{
double power = -meanPower * Math.log(1.0 - random.nextDouble());
double decibels = 10.0 * Math.log10(power);
if(frame >= 200)
{
linearSum += power;
decibelSum += decibels;
count++;
}
}
}
assertEquals(-60.0, 10.0 * Math.log10(linearSum / count), 0.05);
//The bias of averaging decibel values that the linear averaging avoids
assertEquals(-62.5, decibelSum / count, 0.1);
}
@Test
void steadyInputSettlesAtTheInputLevel()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(10);
float[] frame = {-80f, -40f, -60f};
SpectrumAverager.Frame result = null;
for(int x = 0; x < 200; x++)
{
result = averager.process(frame);
}
assertEquals(-80f, result.average()[0], 0.01f);
assertEquals(-40f, result.average()[1], 0.01f);
assertEquals(-60f, result.average()[2], 0.01f);
}
@Test
void signalsRiseFasterThanTheyFall()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(10);
averager.process(new float[]{-80f});
//One frame of a strong signal rises 2.5x as far (in linear power) as one frame of it disappearing falls
float risen = averager.process(new float[]{-20f}).average()[0];
double risePower = SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(-80f);
assertEquals(0.25 * (SpectrumAverager.toPower(-20f) - SpectrumAverager.toPower(-80f)), risePower, 1E-6);
float fallen = averager.process(new float[]{-80f}).average()[0];
double fallPower = SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(fallen);
assertEquals(0.1 * (SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(-80f)), fallPower, 1E-6);
}
@Test
void fastestSettingFollowsTheInput()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(SpectrumAverager.AVERAGING_MINIMUM);
averager.process(new float[]{-80f});
assertEquals(-20f, averager.process(new float[]{-20f}).average()[0], 0.001f);
assertEquals(-90f, averager.process(new float[]{-90f}).average()[0], 0.001f);
}
@Test
void peakHoldHoldsAndFalls()
{
SpectrumAverager averager = new SpectrumAverager();
assertNull(averager.process(new float[]{-80f}).peak());
averager.setPeakHold(true);
assertEquals(-30f, averager.process(new float[]{-30f}).peak()[0], 0.001f);
float[] peak = null;
for(int x = 0; x < 10; x++)
{
peak = averager.process(new float[]{-80f}).peak();
}
assertEquals(-30f - (10 * SpectrumAverager.PEAK_DECAY_DB), peak[0], 0.001f);
assertTrue(peak[0] > -80f);
averager.resetPeak();
assertEquals(-80f, averager.process(new float[]{-80f}).peak()[0], 0.001f);
averager.setPeakHold(false);
assertNull(averager.process(new float[]{-80f}).peak());
}
@Test
void resolutionLabels()
{
assertEquals("585.9 Hz/bin", SpectralDisplayConfigPanel.formatResolution(2_400_000.0 / 4096));
assertEquals("2.44 kHz/bin", SpectralDisplayConfigPanel.formatResolution(10_000_000.0 / 4096));
assertEquals("73.2 Hz/bin", SpectralDisplayConfigPanel.formatResolution(2_400_000.0 / 32768));
}
}

View File

@@ -0,0 +1,42 @@
/*
* *****************************************************************************
* 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.gui.spectrum.layer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests the decibel scale grid step.
*/
public class DecibelLayerTest
{
@Test
void stepKeepsLinesAtLeast28PixelsApart()
{
//100 dB over 300 pixels = 3 pixels per dB: 10 dB = 30 pixels
assertEquals(10.0, DecibelLayer.getStep(100, 300));
//100 dB over 1000 pixels = 10 pixels per dB: 5 dB = 50 pixels
assertEquals(5.0, DecibelLayer.getStep(100, 1000));
//100 dB over 100 pixels = 1 pixel per dB: 50 dB = 50 pixels
assertEquals(50.0, DecibelLayer.getStep(100, 100));
assertEquals(50.0, DecibelLayer.getStep(100, 0));
}
}

View File

@@ -0,0 +1,44 @@
/*
* *****************************************************************************
* 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.gui.spectrum.layer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests the frequency label spacing.
*/
public class FrequencyLayerTest
{
@Test
void niceStepRoundsUpToOneTwoOrFive()
{
assertEquals(1, FrequencyLayer.niceStep(0.3));
assertEquals(1, FrequencyLayer.niceStep(1));
assertEquals(2, FrequencyLayer.niceStep(1.5));
assertEquals(5, FrequencyLayer.niceStep(3));
assertEquals(10, FrequencyLayer.niceStep(7));
assertEquals(100_000, FrequencyLayer.niceStep(100_000));
assertEquals(200_000, FrequencyLayer.niceStep(120_000));
assertEquals(500_000, FrequencyLayer.niceStep(250_001));
assertEquals(1_000_000, FrequencyLayer.niceStep(600_000));
}
}

View File

@@ -0,0 +1,148 @@
/*
* *****************************************************************************
* 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.spectrum;
import io.github.dsheirer.dsp.window.WindowFactory;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter;
import java.util.Random;
import org.jtransforms.fft.FloatFFT_1D;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the dBFS computation: unity gain window, DFT and full scale decibel conversion, the same steps the
* spectral display's DFT processor uses.
*/
public class FullScaleDecibelTest
{
private static final WindowType[] WINDOWS = {WindowType.NONE, WindowType.HANN, WindowType.BLACKMAN_HARRIS_7,
WindowType.FLAT_TOP, WindowType.KAISER};
/**
* Windows, transforms and converts the interleaved samples to dBFS, in display order (DC at size / 2).
*/
private static float[] dbfs(float[] samples, WindowType window)
{
int size = samples.length / 2;
WindowFactory.apply(ComplexDftProcessor.createWindow(window, size, true), samples);
new FloatFFT_1D(size).complexForward(samples);
return FullScaleDecibelConverter.convert(samples);
}
private static float[] tone(int size, int bin, double amplitude)
{
float[] samples = new float[size * 2];
for(int k = 0; k < size; k++)
{
double phase = 2.0 * Math.PI * bin * k / size;
samples[2 * k] = (float)(amplitude * Math.cos(phase));
samples[2 * k + 1] = (float)(amplitude * Math.sin(phase));
}
return samples;
}
@Test
void fullScaleToneIsZeroDbfsForEverySizeAndWindow()
{
for(DFTSize size: DFTSize.values())
{
for(WindowType window: WINDOWS)
{
int n = size.getSize();
int bin = n / 8;
float[] decibels = dbfs(tone(n, bin, 1.0), window);
assertEquals(0.0, decibels[n / 2 + bin], 0.01, size + " " + window);
}
}
}
@Test
void toneLevelFollowsAmplitude()
{
int n = 4096;
//-20 dBFS tone at a negative frequency
float[] decibels = dbfs(tone(n, -300, 0.1), WindowType.BLACKMAN_HARRIS_7);
assertEquals(-20.0, decibels[n / 2 - 300], 0.01);
}
@Test
void noiseFloorIsLoweredByTheProcessingGain()
{
//Complex white noise with total power of 1E-4 (-40 dBFS)
double power = 1E-4;
double sigma = Math.sqrt(power / 2.0);
for(DFTSize size: new DFTSize[]{DFTSize.FFT01024, DFTSize.FFT08192})
{
for(WindowType window: new WindowType[]{WindowType.NONE, WindowType.BLACKMAN_HARRIS_7})
{
int n = size.getSize();
double sum = 0.0;
int frames = 8;
Random random = new Random(7);
for(int frame = 0; frame < frames; frame++)
{
float[] samples = new float[n * 2];
for(int x = 0; x < samples.length; x++)
{
samples[x] = (float)(random.nextGaussian() * sigma);
}
for(float decibel: dbfs(samples, window))
{
sum += Math.pow(10.0, decibel / 10.0);
}
}
double measured = 10.0 * Math.log10(sum / (frames * n));
double expected = -40.0 - 10.0 * Math.log10(n / ComplexDftProcessor.getNoiseBandwidth(window));
assertEquals(expected, measured, 0.3, size + " " + window);
}
}
}
@Test
void noiseBandwidthIsInTheExpectedRange()
{
assertEquals(1.0, ComplexDftProcessor.getNoiseBandwidth(WindowType.NONE), 0.001);
assertEquals(1.5, ComplexDftProcessor.getNoiseBandwidth(WindowType.HANN), 0.01);
double blackmanHarris = ComplexDftProcessor.getNoiseBandwidth(WindowType.BLACKMAN_HARRIS_7);
assertTrue(blackmanHarris > 2.0 && blackmanHarris < 3.5, "BH7 ENBW " + blackmanHarris);
}
@Test
void sameCoefficientIsAppliedToIAndQ()
{
float[] window = ComplexDftProcessor.createWindow(WindowType.HANN, 512, false);
assertEquals(1024, window.length);
for(int x = 0; x < 512; x++)
{
assertEquals(window[2 * x], window[2 * x + 1], 0.0f);
}
}
}