From 9641d2ef59e40ba89b5453e4c4f052b6502124b5 Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Fri, 13 Nov 2026 07:30:31 -0500 Subject: [PATCH] #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 --- .../channel/metadata/NowPlayingPanel.java | 2 +- .../channel/ChannelProcessingManager.java | 8 + .../java/io/github/dsheirer/gui/SDRTrunk.java | 15 +- .../gui/channel/ChannelSpectrumPanel.java | 237 ++-- .../gui/spectrum/ChannelSpectralDisplay.java | 207 +++ .../gui/spectrum/ConfigurationRow.java | 49 + .../gui/spectrum/DftSpectralDisplay.java | 423 ++++++ .../dsheirer/gui/spectrum/DisplayCursor.java | 32 + .../dsheirer/gui/spectrum/DisplaySection.java | 29 + .../dsheirer/gui/spectrum/EdgeSlider.java | 213 +++ .../dsheirer/gui/spectrum/RenderArea.java | 32 + .../gui/spectrum/SpectralDisplay.java | 1188 +++++++++++++++++ .../spectrum/SpectralDisplayConfigPanel.java | 596 +++++++++ .../gui/spectrum/SpectralDisplayLayer.java | 162 +++ .../gui/spectrum/SpectralDisplayModel.java | 316 +++++ .../gui/spectrum/SpectralDisplayPanel.java | 190 +++ .../gui/spectrum/SpectralDisplayTheme.java | 263 ++++ .../gui/spectrum/SpectralDisplayWindow.java | 66 + .../gui/spectrum/SpectrumAverager.java | 176 +++ .../gui/spectrum/TunerSpectralDisplay.java | 354 +++++ .../gui/spectrum/WaterfallPalette.java | 126 ++ .../gui/spectrum/layer/ChannelLayer.java | 744 +++++++++++ .../spectrum/layer/ChannelTuningLayer.java | 253 ++++ .../gui/spectrum/layer/DecibelLayer.java | 183 +++ .../gui/spectrum/layer/FrequencyLayer.java | 313 +++++ .../dsheirer/gui/symbol/SymbolView.java | 55 +- .../dsheirer/source/tuner/ui/TunerEditor.java | 4 +- .../tuner/ui/TunerSpectralDisplayManager.java | 26 +- .../spectrum/ComplexDftProcessor.java | 114 +- .../DisableSpectrumWaterfallMenuItem.java | 7 +- .../spectrum/FrequencyOverlayPanel.java | 608 --------- .../dsheirer/spectrum/OverlayPanel.java | 948 ------------- .../io/github/dsheirer/spectrum/Pausable.java | 29 - .../dsheirer/spectrum/ShowTunerMenuItem.java | 3 +- .../spectrum/SpectralDisplayPanel.java | 904 ------------- .../dsheirer/spectrum/SpectrumFrame.java | 99 -- .../spectrum/WaterfallColorModel.java | 94 -- .../dsheirer/spectrum/WaterfallPanel.java | 425 ------ .../converter/ComplexDecibelConverter.java | 15 +- .../converter/FullScaleDecibelConverter.java | 57 + .../dsheirer/spectrum/menu/AveragingItem.java | 57 - .../dsheirer/spectrum/menu/DFTSizeItem.java | 38 - .../spectrum/menu/FFTWindowTypeItem.java | 57 - .../dsheirer/spectrum/menu/FrameRateItem.java | 57 - .../dsheirer/spectrum/menu/SmoothingItem.java | 107 -- .../spectrum/menu/SmoothingTypeItem.java | 35 - src/main/resources/spectral_display.css | 256 ++++ .../spectrum/SpectralDisplayModelTest.java | 140 ++ .../spectrum/SpectralDisplayThemeTest.java | 72 + .../gui/spectrum/SpectrumAveragerTest.java | 147 ++ .../gui/spectrum/layer/DecibelLayerTest.java | 42 + .../spectrum/layer/FrequencyLayerTest.java | 44 + .../spectrum/FullScaleDecibelTest.java | 148 ++ 53 files changed, 7121 insertions(+), 3644 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/ChannelSpectralDisplay.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/ConfigurationRow.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/DftSpectralDisplay.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/DisplayCursor.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/DisplaySection.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/RenderArea.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayConfigPanel.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModel.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayPanel.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayTheme.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayWindow.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/SpectrumAverager.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/WaterfallPalette.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelTuningLayer.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayer.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/FrequencyOverlayPanel.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/OverlayPanel.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/Pausable.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/SpectralDisplayPanel.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/SpectrumFrame.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/WaterfallColorModel.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/WaterfallPanel.java create mode 100644 src/main/java/io/github/dsheirer/spectrum/converter/FullScaleDecibelConverter.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/AveragingItem.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/DFTSizeItem.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/FFTWindowTypeItem.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/FrameRateItem.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/SmoothingItem.java delete mode 100644 src/main/java/io/github/dsheirer/spectrum/menu/SmoothingTypeItem.java create mode 100644 src/main/resources/spectral_display.css create mode 100644 src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModelTest.java create mode 100644 src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayThemeTest.java create mode 100644 src/test/java/io/github/dsheirer/gui/spectrum/SpectrumAveragerTest.java create mode 100644 src/test/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayerTest.java create mode 100644 src/test/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayerTest.java create mode 100644 src/test/java/io/github/dsheirer/spectrum/FullScaleDecibelTest.java diff --git a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java index e0119f17..ce1eb97b 100644 --- a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java +++ b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java @@ -57,7 +57,7 @@ public class NowPlayingPanel extends JPanel mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel()); mMessageActivityPanel = new MessageActivityPanel(userPreferences); mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences); - mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager); + mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager); mDetailTabsVisible = detailTabsVisible; init(); diff --git a/src/main/java/io/github/dsheirer/controller/channel/ChannelProcessingManager.java b/src/main/java/io/github/dsheirer/controller/channel/ChannelProcessingManager.java index 792c04ee..9f1739e2 100644 --- a/src/main/java/io/github/dsheirer/controller/channel/ChannelProcessingManager.java +++ b/src/main/java/io/github/dsheirer/controller/channel/ChannelProcessingManager.java @@ -153,6 +153,14 @@ public class ChannelProcessingManager implements Listener return !mProcessingChainsMap.isEmpty(); } + /** + * Channels that currently have a processing chain, including temporary traffic channels. + */ + public List getProcessingChannels() + { + return new ArrayList<>(mProcessingChainsMap.keySet()); + } + /** * Returns the current processing chain associated with the channel, or * null if a processing chain is not currently setup for the channel diff --git a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java index 09b52b65..b17b428f 100644 --- a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java +++ b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java @@ -59,7 +59,7 @@ import io.github.dsheirer.source.tuner.manager.TunerManager; import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager; import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem; import io.github.dsheirer.spectrum.ShowTunerMenuItem; -import io.github.dsheirer.spectrum.SpectralDisplayPanel; +import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel; import io.github.dsheirer.util.DuplicateLibraryDetector; import io.github.dsheirer.util.ThreadPool; import io.github.dsheirer.util.TimeStamp; @@ -232,10 +232,13 @@ public class SDRTrunk implements Listener mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible); } - mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel()); + if(!headless) + { + mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel()); + } TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel, - mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel()); + mPlaylistManager, mTunerManager.getDiscoveredTunerModel()); mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager); mTunerManager.getDiscoveredTunerModel().addListener(this); @@ -640,14 +643,14 @@ public class SDRTrunk implements Listener (mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH); mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize()); mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize()); + mLog.info("Stopping spectral display ..."); + mSpectralPanel.dispose(); mJavaFxWindowManager.shutdown(); mLog.info("Stopping channels ..."); mPlaylistManager.getChannelProcessingManager().shutdown(); mAudioRecordingManager.stop(); mResourceMonitor.stop(); - mLog.info("Stopping spectral display ..."); - mSpectralPanel.clearTuner(); mLog.info("Stopping tuners ..."); mTunerManager.stop(); mLog.info("Shutdown complete."); @@ -816,7 +819,7 @@ public class SDRTrunk implements Listener updateTitle(null); break; case NOTIFICATION_SHUTTING_DOWN: - Tuner currentTuner = mSpectralPanel.getTuner(); + Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null; if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == null) { diff --git a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java index 90116bd1..e2e1a6dc 100644 --- a/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java +++ b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java @@ -22,6 +22,8 @@ package io.github.dsheirer.gui.channel; import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource; import io.github.dsheirer.gui.power.SignalPowerView; +import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay; +import io.github.dsheirer.gui.spectrum.DftSpectralDisplay; import io.github.dsheirer.gui.squelch.NoiseSquelchView; import io.github.dsheirer.gui.symbol.SymbolView; import io.github.dsheirer.gui.theme.ThemeManager; @@ -33,23 +35,14 @@ import io.github.dsheirer.module.decode.nbfm.NBFMDecoder; import io.github.dsheirer.playlist.PlaylistManager; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule; -import io.github.dsheirer.settings.SettingsManager; import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification; import io.github.dsheirer.source.Source; import io.github.dsheirer.source.SourceEvent; import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource; import io.github.dsheirer.source.tuner.channel.TunerChannel; import io.github.dsheirer.source.tuner.channel.TunerChannelSource; -import io.github.dsheirer.spectrum.ComplexDftProcessor; -import io.github.dsheirer.spectrum.FrequencyOverlayPanel; -import io.github.dsheirer.spectrum.SpectrumPanel; -import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter; -import io.github.dsheirer.spectrum.converter.DFTResultsConverter; import java.awt.Component; import java.awt.EventQueue; -import java.awt.event.ComponentEvent; -import java.awt.event.ComponentListener; -import java.awt.event.MouseEvent; import java.net.URL; import java.text.DecimalFormat; import java.util.List; @@ -63,12 +56,8 @@ import org.slf4j.LoggerFactory; import javax.swing.JButton; import javax.swing.JLabel; -import javax.swing.JLayeredPane; import javax.swing.JPanel; -import javax.swing.JSpinner; import javax.swing.JSplitPane; -import javax.swing.SpinnerNumberModel; -import javax.swing.event.MouseInputAdapter; /** * Display for channel FFT and squelch details @@ -85,18 +74,20 @@ public class ChannelSpectrumPanel extends JPanel implements Listener { - Number number = mNoiseFloorSpinnerModel.getNumber(); - mSpectrumPanel.setSampleSize(number.doubleValue()); - }); - JSpinner noiseFloorSpinner = new JSpinner(mNoiseFloorSpinnerModel); - labelPanel.add(new JLabel("Noise Floor:")); - labelPanel.add(noiseFloorSpinner); - JButton logIndexesButton = new JButton("Log Settings"); logIndexesButton.addActionListener(e -> { if(mProcessingChain != null) @@ -199,32 +181,16 @@ public class ChannelSpectrumPanel extends JPanel implements Listener { + ChannelSpectralDisplay display = new ChannelSpectralDisplay(); + Scene scene = new Scene(display); + ThemeManager.getInstance().register(scene); + mSpectralPanel.setScene(scene); + displayCreated(display); + }); mNoiseSquelchPanel = new JFXPanel(); mSymbolPanel = new JFXPanel(); @@ -249,6 +215,8 @@ public class ChannelSpectrumPanel extends JPanel implements Listener 0) + { + display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth); + } + + if(mCarrierOffset != null) + { + display.setCarrierOffset(mCarrierOffset); + } + + updateFFTProcessing(); + } + + private synchronized void setChannel(long frequency, int sampleRate, int bandwidth) + { + mChannelFrequency = frequency; + mChannelSampleRate = sampleRate; + mChannelBandwidth = bandwidth; + mCarrierOffset = null; + + if(mChannelSpectralDisplay != null) + { + mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth); + } + } + + private synchronized void setCarrierOffset(long offset) + { + mCarrierOffset = offset; + + if(mChannelSpectralDisplay != null) + { + mChannelSpectralDisplay.setCarrierOffset(offset); + } } /** @@ -295,46 +301,28 @@ public class ChannelSpectrumPanel extends JPanel implements Listener + * **************************************************************************** + */ + +package io.github.dsheirer.gui.spectrum; + +import io.github.dsheirer.buffer.INativeBuffer; +import io.github.dsheirer.gui.spectrum.layer.ChannelTuningLayer; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.sample.Listener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.layout.StackPane; + +/** + * Spectral display for a single decoding channel's sample stream, centered on the channel frequency. The channel + * tuning layer marks the channel passband and the decoder's estimated carrier position (PLL). + * + * Construct on the JavaFX application thread. The other methods can be called from any thread. Feed the channel's + * complex sample buffers to getSampleListener() and turn processing on with setActive(true) while the display is + * showing. + */ +public class ChannelSpectralDisplay extends DftSpectralDisplay +{ + public static final String SETTINGS_PREFIX = "channel.spectral.display"; + public static final double DEFAULT_SAMPLE_SIZE = 18.0; + public static final double MINIMUM_SAMPLE_SIZE = 8.0; + public static final double MAXIMUM_SAMPLE_SIZE = 36.0; + private static final String NOISE_FLOOR = "noise.floor"; + private static final String MESSAGE_NO_CHANNEL = "Select a decoding channel to view its spectrum"; + + private final ChannelTuningLayer mTuningLayer; + private final EdgeSlider mNoiseFloorSlider; + private final Object mLock = new Object(); + private boolean mActive; + private boolean mHasChannel; + + /** + * Constructs an instance. + */ + public ChannelSpectralDisplay() + { + this(new ChannelTuningLayer()); + } + + private ChannelSpectralDisplay(ChannelTuningLayer tuningLayer) + { + super(SETTINGS_PREFIX, true, tuningLayer); + mTuningLayer = tuningLayer; + SpectralDisplayModel model = getSpectralDisplay().getModel(); + getSpectralDisplay().messageProperty().set(MESSAGE_NO_CHANNEL); + + //Noise floor slider along the right edge, below the configuration panel tab + mNoiseFloorSlider = new EdgeSlider("Floor", "Noise floor: moves the bottom of the spectrum's dB range. " + + "Higher settings show a deeper range and move the noise floor up the display.", + MINIMUM_SAMPLE_SIZE, MAXIMUM_SAMPLE_SIZE, 1, this::formatPlotBottom); + double sampleSize = SystemProperties.getInstance().get(key(NOISE_FLOOR), (int)DEFAULT_SAMPLE_SIZE); + sampleSize = Math.max(MINIMUM_SAMPLE_SIZE, Math.min(MAXIMUM_SAMPLE_SIZE, sampleSize)); + model.sampleSizeProperty().set(sampleSize); + mNoiseFloorSlider.valueProperty().set(sampleSize); + mNoiseFloorSlider.valueProperty().addListener((ob, ov, nv) -> { + int value = (int)Math.round(nv.doubleValue()); + model.sampleSizeProperty().set(value); + save(NOISE_FLOOR, value); + }); + //The value shows the plot bottom in dBFS, which also depends on the DFT size and window + model.binCountProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel()); + model.noiseBandwidthProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel()); + mNoiseFloorSlider.refreshLabel(); + StackPane.setAlignment(mNoiseFloorSlider, Pos.CENTER_RIGHT); + StackPane.setMargin(mNoiseFloorSlider, new Insets(44, 0, 8, 0)); + getChildren().add(mNoiseFloorSlider); + + installConfigPanel(); + } + + /** + * Listener for the channel's complex sample buffers. + */ + public Listener getSampleListener() + { + return getDftProcessor(); + } + + /** + * Shows a channel. + * @param frequency of the channel center in Hertz + * @param sampleRate of the channel sample stream in Hertz + * @param bandwidth of the channel in Hertz, or zero to hide the passband markers + */ + public void setChannel(long frequency, int sampleRate, int bandwidth) + { + synchronized(mLock) + { + mHasChannel = sampleRate > 0; + updateProcessing(); + } + + getSpectralDisplay().clear(); + runOnFxThread(() -> { + SpectralDisplayModel model = getSpectralDisplay().getModel(); + model.resetZoom(); + model.centerFrequencyProperty().set(frequency); + model.sampleRateProperty().set(sampleRate); + mTuningLayer.channelBandwidthProperty().set(bandwidth); + mTuningLayer.carrierOffsetValidProperty().set(false); + mTuningLayer.carrierOffsetProperty().set(0); + getSpectralDisplay().messageProperty().set(sampleRate > 0 ? null : MESSAGE_NO_CHANNEL); + }); + } + + /** + * Stops showing the channel. + */ + public void clearChannel() + { + setChannel(0, 0, 0); + } + + /** + * Sets the estimated carrier offset from the channel center, from the decoder's frequency correction. + * @param offset in Hertz: where the signal is relative to the channel center + */ + public void setCarrierOffset(long offset) + { + runOnFxThread(() -> { + mTuningLayer.carrierOffsetProperty().set(offset); + mTuningLayer.carrierOffsetValidProperty().set(true); + }); + } + + /** + * Noise floor slider along the right edge of the display. + */ + public EdgeSlider getNoiseFloorSlider() + { + return mNoiseFloorSlider; + } + + /** + * Formats the bottom of the spectrum plot in dBFS for the noise floor slider value. + */ + private String formatPlotBottom(double sampleSize) + { + double range = (20.0 * Math.log10(Math.pow(2.0, sampleSize - 1.0))) + + getSpectralDisplay().getModel().getProcessingGain(); + return Math.round(-range) + " dB"; + } + + /** + * Turns DFT processing on while the display is showing, and off when it's hidden. + */ + public void setActive(boolean active) + { + synchronized(mLock) + { + mActive = active; + updateProcessing(); + } + } + + @Override + protected void processingEnabledChanged() + { + synchronized(mLock) + { + updateProcessing(); + } + } + + /** + * Starts or stops the DFT processor. Hold the lock when calling. + */ + private void updateProcessing() + { + if(mActive && mHasChannel && isProcessingEnabled()) + { + startDft(); + } + else + { + stopDft(); + } + } + + @Override + public void dispose() + { + setActive(false); + super.dispose(); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/ConfigurationRow.java b/src/main/java/io/github/dsheirer/gui/spectrum/ConfigurationRow.java new file mode 100644 index 00000000..c1d7e3d0 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/ConfigurationRow.java @@ -0,0 +1,49 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import javafx.beans.property.BooleanProperty; +import javafx.scene.Node; +import org.controlsfx.control.ToggleSwitch; + +/** + * Labeled control that a display layer adds to its section of the configuration panel. + * + * @param label shown to the left of the control + * @param control to change the setting + */ +public record ConfigurationRow(String label, Node control) +{ + private static final double SWITCH_WIDTH = 40; + + /** + * Creates a row with an on/off switch bound to the property. + */ + public static ConfigurationRow toggle(String label, BooleanProperty property) + { + ToggleSwitch toggleSwitch = new ToggleSwitch(); + toggleSwitch.selectedProperty().bindBidirectional(property); + //The switch skin reserves space for its text even when there is none, so size it to the thumb track + toggleSwitch.setMinWidth(SWITCH_WIDTH); + toggleSwitch.setPrefWidth(SWITCH_WIDTH); + toggleSwitch.setMaxWidth(SWITCH_WIDTH); + return new ConfigurationRow(label, toggleSwitch); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/DftSpectralDisplay.java b/src/main/java/io/github/dsheirer/gui/spectrum/DftSpectralDisplay.java new file mode 100644 index 00000000..71f68d40 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/DftSpectralDisplay.java @@ -0,0 +1,423 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import io.github.dsheirer.dsp.window.WindowType; +import io.github.dsheirer.gui.spectrum.layer.DecibelLayer; +import io.github.dsheirer.gui.spectrum.layer.FrequencyLayer; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.spectrum.ComplexDftProcessor; +import io.github.dsheirer.spectrum.DFTSize; +import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter; +import javafx.application.Platform; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.layout.StackPane; + +/** + * Base class for a spectral display fed by DFT processing of a complex sample stream: the spectral display with the + * decibel and frequency layers plus any layers the subclass adds, the DFT processor and dBFS converter, the + * collapsible configuration panel, and saving the display settings under a property key prefix. + * + * The color theme is shared by all spectral displays in the application. + * + * Construct on the JavaFX application thread. Subclasses call installConfigPanel() at the end of their constructor. + */ +public abstract class DftSpectralDisplay extends StackPane +{ + public static final int[] FRAME_RATES = {10, 14, 16, 18, 20, 25, 30, 40, 50, 60}; + public static final String PROPERTY_THEME = "spectral.display.theme"; + private static final String DFT_SIZE = "dft.size"; + private static final String WINDOW_TYPE = "window.type"; + private static final String FRAME_RATE = "frame.rate"; + private static final String AVERAGING = "averaging"; + private static final String PEAK_HOLD = "peak.hold"; + private static final String SPECTRUM_ENABLED = "spectrum.enabled"; + private static final String WATERFALL_ENABLED = "waterfall.enabled"; + private static final String DIVIDER_POSITION = "divider.position"; + private static final String DECIBEL_SCALE = "decibel.scale"; + private static final String CONFIG_PINNED = "config.pinned"; + private static ObjectProperty sSharedTheme; + + private final SpectralDisplay mSpectralDisplay; + private final DecibelLayer mDecibelLayer = new DecibelLayer(); + private final ComplexDftProcessor mDftProcessor = new ComplexDftProcessor(); + private final FullScaleDecibelConverter mDecibelConverter = new FullScaleDecibelConverter(); + private final String mSettingsPrefix; + private final boolean mSaveSettings; + private final ObjectProperty mDftSize = new SimpleObjectProperty<>(DFTSize.FFT04096); + private final ObjectProperty mWindowType = new SimpleObjectProperty<>(WindowType.BLACKMAN_HARRIS_7); + private final IntegerProperty mFrameRate = new SimpleIntegerProperty(20); + private SpectralDisplayConfigPanel mConfigPanel; + private volatile boolean mProcessingEnabled; + private volatile boolean mDftRunning; + + /** + * Constructs an instance. + * @param settingsPrefix for the saved setting keys, for example "spectral.display" + * @param saveSettings true to save setting changes, false to load the saved settings but not change them + * @param layers to draw after the decibel scale and frequency layers + */ + protected DftSpectralDisplay(String settingsPrefix, boolean saveSettings, SpectralDisplayLayer... layers) + { + mSettingsPrefix = settingsPrefix; + mSaveSettings = saveSettings; + + SpectralDisplayLayer[] all = new SpectralDisplayLayer[layers.length + 2]; + all[0] = mDecibelLayer; + all[1] = new FrequencyLayer(); + System.arraycopy(layers, 0, all, 2, layers.length); + mSpectralDisplay = new SpectralDisplay(all); + + mDftProcessor.setUnityGainWindow(true); + mDftProcessor.addConverter(mDecibelConverter); + mDecibelConverter.addListener(mSpectralDisplay::receive); + //The processor starts its timer on construction - leave it stopped until there is a sample source + mDftProcessor.stop(); + + loadSettings(); + + mDftSize.addListener((ob, ov, nv) -> { + mDftProcessor.setDFTSize(nv); + mSpectralDisplay.getModel().resetZoom(); + save(DFT_SIZE, nv.name()); + }); + mWindowType.addListener((ob, ov, nv) -> { + mDftProcessor.setWindowType(nv); + mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(nv)); + save(WINDOW_TYPE, nv.name()); + }); + mFrameRate.addListener((ob, ov, nv) -> { + mDftProcessor.setFrameRateNoSave(nv.intValue()); + + if(!mDftRunning) + { + //Changing the frame rate restarts the timer - keep it stopped while not processing + mDftProcessor.stop(); + } + + save(FRAME_RATE, nv.intValue()); + }); + mSpectralDisplay.averagingProperty().addListener((ob, ov, nv) -> save(AVERAGING, nv.intValue())); + mSpectralDisplay.peakHoldProperty().addListener((ob, ov, nv) -> save(PEAK_HOLD, nv)); + mSpectralDisplay.dividerPositionProperty().addListener((ob, ov, nv) -> + save(DIVIDER_POSITION, String.valueOf(nv.doubleValue()))); + mDecibelLayer.visibleProperty().addListener((ob, ov, nv) -> save(DECIBEL_SCALE, nv)); + mSpectralDisplay.spectrumEnabledProperty().addListener((ob, ov, nv) -> { + save(SPECTRUM_ENABLED, nv); + updateProcessingEnabled(); + }); + mSpectralDisplay.waterfallEnabledProperty().addListener((ob, ov, nv) -> { + save(WATERFALL_ENABLED, nv); + updateProcessingEnabled(); + }); + mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() || + mSpectralDisplay.waterfallEnabledProperty().get(); + + mSpectralDisplay.themeProperty().bindBidirectional(getSharedTheme()); + + getChildren().add(mSpectralDisplay); + setMinSize(0, 0); + } + + /** + * Creates the configuration panel. Subclasses call this at the end of their constructor, after their own + * settings are loaded, since the panel reads the subclass's controls. + */ + protected void installConfigPanel() + { + mConfigPanel = new SpectralDisplayConfigPanel(this); + mConfigPanel.pinnedProperty().set(SystemProperties.getInstance().get(key(CONFIG_PINNED), false)); + mConfigPanel.pinnedProperty().addListener((ob, ov, nv) -> save(CONFIG_PINNED, nv)); + StackPane.setAlignment(mConfigPanel, Pos.TOP_RIGHT); + StackPane.setMargin(mConfigPanel, new Insets(6)); + getChildren().add(mConfigPanel); + } + + /** + * Color theme shared by all spectral displays, loaded from and saved to the theme setting. JavaFX thread only. + */ + public static ObjectProperty getSharedTheme() + { + if(sSharedTheme == null) + { + SpectralDisplayTheme theme = parse(SpectralDisplayTheme.class, SystemProperties.getInstance() + .get(PROPERTY_THEME, SpectralDisplayTheme.getDefault().name()), SpectralDisplayTheme.getDefault()); + sSharedTheme = new SimpleObjectProperty<>(theme); + sSharedTheme.addListener((ob, ov, nv) -> { + if(nv != null) + { + SystemProperties.getInstance().set(PROPERTY_THEME, nv.name()); + } + }); + } + + return sSharedTheme; + } + + private void loadSettings() + { + SystemProperties properties = SystemProperties.getInstance(); + DFTSize dftSize = parse(DFTSize.class, properties.get(key(DFT_SIZE), DFTSize.FFT04096.name()), + DFTSize.FFT04096); + mDftSize.set(dftSize); + mDftProcessor.setDFTSize(dftSize); + + WindowType windowType = parse(WindowType.class, properties.get(key(WINDOW_TYPE), + WindowType.BLACKMAN_HARRIS_7.name()), WindowType.BLACKMAN_HARRIS_7); + + if(!isWindowSupported(windowType)) + { + windowType = WindowType.BLACKMAN_HARRIS_7; + } + + mWindowType.set(windowType); + mDftProcessor.setWindowType(windowType); + mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(windowType)); + + int frameRate = Math.max(1, Math.min(100, properties.get(key(FRAME_RATE), mDftProcessor.getFrameRate()))); + mFrameRate.set(frameRate); + mDftProcessor.setFrameRateNoSave(frameRate); + mDftProcessor.stop(); + + int averaging = properties.get(key(AVERAGING), SpectralDisplay.AVERAGING_DEFAULT); + mSpectralDisplay.averagingProperty().set(Math.max(SpectralDisplay.AVERAGING_MINIMUM, + Math.min(SpectralDisplay.AVERAGING_MAXIMUM, averaging))); + mSpectralDisplay.peakHoldProperty().set(properties.get(key(PEAK_HOLD), false)); + mDecibelLayer.visibleProperty().set(properties.get(key(DECIBEL_SCALE), true)); + + try + { + double divider = Double.parseDouble(properties.get(key(DIVIDER_POSITION), "0.5")); + mSpectralDisplay.dividerPositionProperty().set(Math.max(0.05, Math.min(0.95, divider))); + } + catch(NumberFormatException nfe) + { + mSpectralDisplay.dividerPositionProperty().set(0.5); + } + + mSpectralDisplay.spectrumEnabledProperty().set(properties.get(key(SPECTRUM_ENABLED), true)); + mSpectralDisplay.waterfallEnabledProperty().set(properties.get(key(WATERFALL_ENABLED), true)); + } + + /** + * Full property key for the setting name. + */ + protected String key(String name) + { + return mSettingsPrefix + "." + name; + } + + /** + * Indicates if the window type can be used for the spectral display. Kaiser windows need extra parameters. + */ + public static boolean isWindowSupported(WindowType windowType) + { + return windowType != null && windowType != WindowType.KAISER; + } + + /** + * Parses an enum setting value, or returns the default for a missing or unrecognized value. + */ + protected static > E parse(Class type, String value, E defaultValue) + { + if(value != null) + { + try + { + return Enum.valueOf(type, value); + } + catch(IllegalArgumentException iae) + { + //Fall through to the default + } + } + + return defaultValue; + } + + protected void save(String name, String value) + { + if(mSaveSettings) + { + SystemProperties.getInstance().set(key(name), value); + } + } + + protected void save(String name, int value) + { + if(mSaveSettings) + { + SystemProperties.getInstance().set(key(name), value); + } + } + + protected void save(String name, boolean value) + { + if(mSaveSettings) + { + SystemProperties.getInstance().set(key(name), value); + } + } + + /** + * The spectral display. + */ + public SpectralDisplay getSpectralDisplay() + { + return mSpectralDisplay; + } + + /** + * Collapsible configuration panel, or null before installConfigPanel() is called. + */ + public SpectralDisplayConfigPanel getConfigPanel() + { + return mConfigPanel; + } + + /** + * DFT processor. Register it with the sample source to feed the display. + */ + protected ComplexDftProcessor getDftProcessor() + { + return mDftProcessor; + } + + /** + * DFT size. + */ + public ObjectProperty dftSizeProperty() + { + return mDftSize; + } + + /** + * DFT window type. + */ + public ObjectProperty windowTypeProperty() + { + return mWindowType; + } + + /** + * DFT frames per second. + */ + public IntegerProperty frameRateProperty() + { + return mFrameRate; + } + + /** + * Indicates if the spectrum or the waterfall is turned on. Safe to read from any thread. + */ + protected boolean isProcessingEnabled() + { + return mProcessingEnabled; + } + + private void updateProcessingEnabled() + { + mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() || + mSpectralDisplay.waterfallEnabledProperty().get(); + processingEnabledChanged(); + } + + /** + * Called on the JavaFX thread when the spectrum and waterfall are both turned off, or one is turned back on. + * Subclasses start or stop their sample source with isProcessingEnabled(). + */ + protected abstract void processingEnabledChanged(); + + /** + * Starts the DFT processor timer. + */ + protected void startDft() + { + if(!mDftRunning) + { + mDftRunning = true; + mDftProcessor.clearBuffer(); + mDftProcessor.start(); + } + } + + /** + * Stops the DFT processor timer and clears the display. + */ + protected void stopDft() + { + if(mDftRunning) + { + mDftRunning = false; + mDftProcessor.stop(); + mDftProcessor.clearBuffer(); + mSpectralDisplay.clear(); + } + } + + /** + * Indicates if the DFT processor timer is running. + */ + protected boolean isDftRunning() + { + return mDftRunning; + } + + /** + * Runs the runnable on the JavaFX application thread, ignoring the request after the JavaFX toolkit has exited + * during application shutdown. + */ + protected static void runOnFxThread(Runnable runnable) + { + try + { + Platform.runLater(runnable); + } + catch(IllegalStateException ise) + { + //JavaFX toolkit has exited + } + } + + /** + * Releases the DFT processing and the display. Subclasses disconnect their sample source first. + */ + public void dispose() + { + stopDft(); + mDftProcessor.dispose(); + mDecibelConverter.dispose(); + runOnFxThread(() -> { + mSpectralDisplay.themeProperty().unbindBidirectional(getSharedTheme()); + + if(mConfigPanel != null) + { + mConfigPanel.dispose(); + } + + mSpectralDisplay.dispose(); + }); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/DisplayCursor.java b/src/main/java/io/github/dsheirer/gui/spectrum/DisplayCursor.java new file mode 100644 index 00000000..b97411aa --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/DisplayCursor.java @@ -0,0 +1,32 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +/** + * Mouse cursor position over the spectral display. + * + * @param section under the mouse + * @param x position in pixels within the section + * @param y position in pixels within the section + * @param frequency under the mouse in Hertz + */ +public record DisplayCursor(DisplaySection section, double x, double y, long frequency) +{ +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/DisplaySection.java b/src/main/java/io/github/dsheirer/gui/spectrum/DisplaySection.java new file mode 100644 index 00000000..7a6419ed --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/DisplaySection.java @@ -0,0 +1,29 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +/** + * Sections of the spectral display. + */ +public enum DisplaySection +{ + SPECTRUM, + WATERFALL; +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java b/src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java new file mode 100644 index 00000000..d9f774c7 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java @@ -0,0 +1,213 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import java.net.URL; +import java.util.function.DoubleFunction; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.beans.property.DoubleProperty; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.Slider; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.util.Duration; + +/** + * Vertical slider that floats along the right edge of a display. While collapsed it shows as a thin strip with a + * marker at the current value. Moving the mouse over the strip expands it into a translucent panel with a title, a + * vertical slider and the formatted value. It collapses again shortly after the mouse leaves, unless the slider is + * being dragged. + * + * Place it in a StackPane aligned to the right edge. The slider's minimum is at the bottom. + */ +public class EdgeSlider extends StackPane +{ + private static final String STYLESHEET = "/spectral_display.css"; + private static final double COLLAPSED_WIDTH = 14; + private static final double STRIP_WIDTH = 4; + private static final double MARKER_HEIGHT = 10; + private static final double EXPANDED_WIDTH = 64; + private static final Duration COLLAPSE_DELAY = Duration.millis(400); + private static final Duration FADE_DURATION = Duration.millis(120); + + private final Slider mSlider; + private final Label mValueLabel = new Label(); + private final DoubleFunction mFormatter; + private final VBox mExpanded; + private final Pane mCollapsed = new Pane(); + private final Region mStrip = new Region(); + private final Region mMarker = new Region(); + private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY); + private boolean mIsExpanded = false; + + /** + * Constructs an instance. + * @param title shown above the slider + * @param tooltip describing the control + * @param min value, at the bottom of the slider + * @param max value, at the top of the slider + * @param step between values + * @param formatter for the value shown below the slider + */ + public EdgeSlider(String title, String tooltip, double min, double max, double step, + DoubleFunction formatter) + { + mFormatter = formatter; + URL stylesheet = EdgeSlider.class.getResource(STYLESHEET); + + if(stylesheet != null) + { + getStylesheets().add(stylesheet.toExternalForm()); + } + + getStyleClass().add("spectral-edge-slider"); + setMaxWidth(Region.USE_PREF_SIZE); + setMinWidth(Region.USE_PREF_SIZE); + setPickOnBounds(true); + + mSlider = new Slider(min, max, min); + mSlider.setOrientation(Orientation.VERTICAL); + mSlider.setMajorTickUnit(step); + mSlider.setMinorTickCount(0); + mSlider.setBlockIncrement(step); + mSlider.setSnapToTicks(true); + mSlider.setMaxHeight(Double.MAX_VALUE); + VBox.setVgrow(mSlider, Priority.ALWAYS); + mSlider.valueProperty().addListener((ob, ov, nv) -> { + refreshLabel(); + requestLayout(); + }); + + Label titleLabel = new Label(title); + titleLabel.getStyleClass().add("spectral-edge-slider-title"); + mValueLabel.getStyleClass().add("spectral-edge-slider-value"); + mExpanded = new VBox(4, titleLabel, mSlider, mValueLabel); + mExpanded.setAlignment(Pos.CENTER); + mExpanded.getStyleClass().add("spectral-edge-slider-panel"); + mExpanded.setPrefWidth(EXPANDED_WIDTH); + Tooltip.install(mExpanded, new Tooltip(tooltip)); + + mStrip.getStyleClass().add("spectral-edge-slider-strip"); + mMarker.getStyleClass().add("spectral-edge-slider-marker"); + //Unmanaged so the pane doesn't resize them to their (empty) preferred size - layoutChildren() places them + mStrip.setManaged(false); + mMarker.setManaged(false); + mCollapsed.getChildren().addAll(mStrip, mMarker); + mCollapsed.setPrefWidth(COLLAPSED_WIDTH); + mCollapsed.setMinWidth(COLLAPSED_WIDTH); + + mCollapseTimer.setOnFinished(e -> { + if(!isHover() && !mSlider.isValueChanging() && !mSlider.isPressed()) + { + setExpanded(false); + } + }); + setOnMouseEntered(e -> { + mCollapseTimer.stop(); + setExpanded(true); + }); + setOnMouseExited(e -> mCollapseTimer.playFromStart()); + mSlider.valueChangingProperty().addListener((ob, ov, nv) -> { + if(!nv && !isHover()) + { + mCollapseTimer.playFromStart(); + } + }); + + getChildren().add(mCollapsed); + refreshLabel(); + } + + /** + * Slider value. + */ + public DoubleProperty valueProperty() + { + return mSlider.valueProperty(); + } + + /** + * Updates the value text, for example when something the formatter depends on changes. + */ + public void refreshLabel() + { + mValueLabel.setText(mFormatter != null ? mFormatter.apply(mSlider.getValue()) : + String.valueOf(mSlider.getValue())); + } + + /** + * Indicates if the slider panel is expanded. + */ + public boolean isExpanded() + { + return mIsExpanded; + } + + /** + * Expands or collapses the slider panel. + */ + public void setExpanded(boolean expanded) + { + if(expanded != mIsExpanded) + { + mIsExpanded = expanded; + + if(expanded) + { + getChildren().setAll(mExpanded); + mExpanded.setOpacity(0); + FadeTransition fade = new FadeTransition(FADE_DURATION, mExpanded); + fade.setToValue(1.0); + fade.play(); + } + else + { + getChildren().setAll(mCollapsed); + } + } + } + + @Override + protected void layoutChildren() + { + super.layoutChildren(); + + if(!mIsExpanded) + { + //Strip along the right side of the hover area, with the marker at the value position + double width = mCollapsed.getWidth(); + double height = mCollapsed.getHeight(); + double stripX = width - STRIP_WIDTH - 2; + mStrip.resizeRelocate(stripX, 0, STRIP_WIDTH, height); + + double range = mSlider.getMax() - mSlider.getMin(); + double fraction = range > 0 ? (mSlider.getValue() - mSlider.getMin()) / range : 0; + double markerY = (1.0 - fraction) * Math.max(0, height - MARKER_HEIGHT); + mMarker.resizeRelocate(stripX - 2, markerY, STRIP_WIDTH + 4, MARKER_HEIGHT); + } + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/RenderArea.java b/src/main/java/io/github/dsheirer/gui/spectrum/RenderArea.java new file mode 100644 index 00000000..8d77659f --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/RenderArea.java @@ -0,0 +1,32 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +/** + * Size of a display section for rendering. + * + * @param width of the section in pixels + * @param height of the section in pixels + * @param plotHeight of the area above the bottom inset where the spectrum trace is drawn. This is the same as the + * height for the waterfall. + */ +public record RenderArea(double width, double height, double plotHeight) +{ +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java new file mode 100644 index 00000000..1d6d3146 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java @@ -0,0 +1,1188 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import javafx.animation.AnimationTimer; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Orientation; +import javafx.geometry.VPos; +import javafx.scene.Cursor; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.SplitPane; +import javafx.scene.control.ToggleGroup; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.WritableImage; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.paint.CycleMethod; +import javafx.scene.paint.LinearGradient; +import javafx.scene.paint.Stop; +import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; + +/** + * JavaFX spectral display with a spectrum (frequency domain) section above a scrolling waterfall section. Either + * section can be turned off independently. Optional layers draw on top of each section to add frequency labels, + * channels, cursor readouts, etc. + * + * Send DFT results in dB to receive(float[]) from any thread. Set the frequency, sample rate and sample size with + * the model on the JavaFX application thread. The display redraws on the JavaFX pulse only when something changed. + * + * Mouse: the scroll wheel zooms around the mouse position, a primary button drag pans the zoomed display and the + * secondary button opens a context menu with each layer's menu items. + */ +public class SpectralDisplay extends StackPane +{ + public static final int AVERAGING_MINIMUM = SpectrumAverager.AVERAGING_MINIMUM; + public static final int AVERAGING_MAXIMUM = SpectrumAverager.AVERAGING_MAXIMUM; + public static final int AVERAGING_DEFAULT = SpectrumAverager.AVERAGING_DEFAULT; + private static final int WATERFALL_MIN_ROWS = 256; + private static final int WATERFALL_MAX_ROWS = 4096; + private static final int WATERFALL_QUEUE_LIMIT = 256; + //Below the smallest maximum texture size of common GPUs (8192) + private static final int WATERFALL_TILE_WIDTH = 4096; + private static final Font MESSAGE_FONT = Font.font(13); + private static final Font ZOOM_FONT = Font.font(11); + + private final SpectralDisplayModel mModel = new SpectralDisplayModel(); + private final ObjectProperty mTheme = + new SimpleObjectProperty<>(SpectralDisplayTheme.getDefault()); + private final List mLayers = new CopyOnWriteArrayList<>(); + private final SplitPane mSplitPane = new SplitPane(); + private final CanvasPane mSpectrumPane = new CanvasPane(DisplaySection.SPECTRUM); + private final CanvasPane mWaterfallPane = new CanvasPane(DisplaySection.WATERFALL); + private final StackPane mDisabledPane = new StackPane(); + + private final BooleanProperty mSpectrumEnabled = new SimpleBooleanProperty(true); + private final BooleanProperty mWaterfallEnabled = new SimpleBooleanProperty(true); + private final BooleanProperty mWaterfallPaused = new SimpleBooleanProperty(false); + private final IntegerProperty mAveraging = new SimpleIntegerProperty(AVERAGING_DEFAULT); + private final BooleanProperty mPeakHold = new SimpleBooleanProperty(false); + private final DoubleProperty mDividerPosition = new SimpleDoubleProperty(0.5); + private final StringProperty mMessage = new SimpleStringProperty(); + + //Values read on the DFT results thread + private volatile boolean mSpectrumActive = true; + private volatile boolean mWaterfallActive = true; + private volatile boolean mWaterfallPausedFlag = false; + private final SpectrumAverager mAverager = new SpectrumAverager(); + + //Hand off from the DFT results thread to the JavaFX thread + private final AtomicReference mLatestSpectrum = new AtomicReference<>(); + private final ConcurrentLinkedQueue mWaterfallRows = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean mRenderRequested = new AtomicBoolean(true); + private final AtomicBoolean mClearRequested = new AtomicBoolean(); + + //JavaFX thread state + private final AnimationTimer mAnimationTimer; + private SpectrumAverager.Frame mSpectrum; + //Waterfall image split into side by side tiles, since GPUs limit texture width (as low as 8192 pixels) + private WritableImage[] mWaterfallTiles; + private int mWaterfallWidth; + private int mWaterfallHeight; + private byte[] mWaterfallIndexes; + private int[] mWaterfallPixels; + private int mWaterfallWriteRow = 0; + private double[] mPolygonX = new double[0]; + private double[] mPolygonY = new double[0]; + private DisplayCursor mCursor; + private ContextMenu mContextMenu; + private double mDragStartX; + private double mDragStartOffset; + private boolean mDragging; + private double mScrollAccumulator; + private boolean mDisposed; + + /** + * Constructs an instance. + * @param layers to draw on the display, in drawing order + */ + public SpectralDisplay(SpectralDisplayLayer... layers) + { + mSplitPane.setOrientation(Orientation.VERTICAL); + mSplitPane.getStyleClass().add("spectral-display-split-pane"); + java.net.URL stylesheet = SpectralDisplay.class.getResource("/spectral_display.css"); + + if(stylesheet != null) + { + getStylesheets().add(stylesheet.toExternalForm()); + } + + Label disabledLabel = new Label("Spectrum and waterfall are turned off"); + mDisabledPane.getChildren().add(disabledLabel); + mTheme.addListener((ob, ov, nv) -> themeChanged(disabledLabel)); + themeChanged(disabledLabel); + getChildren().addAll(mDisabledPane, mSplitPane); + setMinSize(0, 0); + + mSpectrumEnabled.addListener((ob, ov, nv) -> updateSections()); + mWaterfallEnabled.addListener((ob, ov, nv) -> updateSections()); + mWaterfallPaused.addListener((ob, ov, nv) -> { + mWaterfallPausedFlag = nv; + requestRender(); + }); + mMessage.addListener((ob, ov, nv) -> requestRender()); + mAveraging.addListener((ob, ov, nv) -> mAverager.setAveraging(nv.intValue())); + mPeakHold.addListener((ob, ov, nv) -> { + mAverager.setPeakHold(nv); + requestRender(); + }); + mModel.centerFrequencyProperty().addListener((ob, ov, nv) -> { + mAverager.resetPeak(); + requestRender(); + }); + mModel.sampleRateProperty().addListener((ob, ov, nv) -> { + mAverager.resetPeak(); + requestRender(); + }); + mModel.zoomProperty().addListener((ob, ov, nv) -> requestRender()); + mModel.zoomOffsetProperty().addListener((ob, ov, nv) -> requestRender()); + mModel.binCountProperty().addListener((ob, ov, nv) -> requestRender()); + mModel.sampleSizeProperty().addListener((ob, ov, nv) -> requestRender()); + mModel.noiseBandwidthProperty().addListener((ob, ov, nv) -> requestRender()); + + //Track user changes to the divider position so it can be restored after the waterfall is turned off and on + mSplitPane.getDividers().addListener((javafx.collections.ListChangeListener)change -> { + while(change.next()) + { + for(SplitPane.Divider divider: change.getAddedSubList()) + { + divider.positionProperty().addListener((ob, ov, nv) -> { + if(mSplitPane.getItems().size() == 2) + { + mDividerPosition.set(nv.doubleValue()); + } + }); + } + } + }); + + updateSections(); + + for(SpectralDisplayLayer layer: layers) + { + addLayer(layer); + } + + mAnimationTimer = new AnimationTimer() + { + @Override + public void handle(long now) + { + pulse(); + } + }; + mAnimationTimer.start(); + } + + /** + * Frequency and zoom model. Access on the JavaFX application thread. + */ + public SpectralDisplayModel getModel() + { + return mModel; + } + + /** + * Color theme for the display, its layers and the waterfall. JavaFX thread only. + */ + public ObjectProperty themeProperty() + { + return mTheme; + } + + /** + * Current color theme. JavaFX thread only. + */ + public SpectralDisplayTheme getTheme() + { + SpectralDisplayTheme theme = mTheme.get(); + return theme != null ? theme : SpectralDisplayTheme.getDefault(); + } + + /** + * Applies a new theme: recolors the placeholder and the waterfall history, then redraws. + */ + private void themeChanged(Label disabledLabel) + { + SpectralDisplayTheme theme = getTheme(); + mDisabledPane.setBackground(new Background(new BackgroundFill(theme.getBackground(), null, null))); + disabledLabel.setTextFill(theme.getLine()); + repaintWaterfallImage(); + requestRender(); + } + + /** + * Adds a layer that draws after the layers already added. + */ + public void addLayer(SpectralDisplayLayer layer) + { + if(layer != null && !mLayers.contains(layer)) + { + mLayers.add(layer); + layer.attach(this); + requestRender(); + } + } + + /** + * Removes the layer from the display. + */ + public void removeLayer(SpectralDisplayLayer layer) + { + if(mLayers.remove(layer)) + { + layer.detach(); + requestRender(); + } + } + + /** + * Layers in drawing order. + */ + public List getLayers() + { + return new ArrayList<>(mLayers); + } + + /** + * Turns the spectrum section on or off. + */ + public BooleanProperty spectrumEnabledProperty() + { + return mSpectrumEnabled; + } + + /** + * Turns the waterfall section on or off. + */ + public BooleanProperty waterfallEnabledProperty() + { + return mWaterfallEnabled; + } + + /** + * Pauses the waterfall scrolling. New DFT results are dropped while paused. + */ + public BooleanProperty waterfallPausedProperty() + { + return mWaterfallPaused; + } + + /** + * Spectrum trace averaging, from AVERAGING_MINIMUM (fast, no averaging) to AVERAGING_MAXIMUM (slow). + * @see SpectrumAverager + */ + public IntegerProperty averagingProperty() + { + return mAveraging; + } + + /** + * Shows a peak hold trace: the highest recent value of each bin, falling slowly. + */ + public BooleanProperty peakHoldProperty() + { + return mPeakHold; + } + + /** + * Divider position (0 - 1.0) between the spectrum and the waterfall when both are showing. + */ + public DoubleProperty dividerPositionProperty() + { + return mDividerPosition; + } + + /** + * Message shown in the middle of the display, for example when no tuner is selected. Null for no message. + */ + public StringProperty messageProperty() + { + return mMessage; + } + + /** + * Mouse cursor over the display, or null when the mouse is not over the display. JavaFX thread only. + */ + public DisplayCursor getDisplayCursor() + { + return mCursor; + } + + /** + * Asks the display to redraw on the next pulse. Safe to call from any thread. + */ + public void requestRender() + { + mRenderRequested.set(true); + } + + /** + * Clears the spectrum trace, the averaging history and the waterfall. Safe to call from any thread. + */ + public void clear() + { + mAverager.reset(); + + mLatestSpectrum.set(null); + mWaterfallRows.clear(); + mClearRequested.set(true); + requestRender(); + } + + /** + * Receives DFT results in dB (0 dB is full scale) in display order. Safe to call from any thread. + */ + public void receive(float[] bins) + { + if(bins == null || bins.length == 0 || mDisposed) + { + return; + } + + //The first DFT results on startup can be NaN or infinite + if(Float.isNaN(bins[0]) || Float.isInfinite(bins[0])) + { + bins = new float[bins.length]; + } + + if(mSpectrumActive) + { + mLatestSpectrum.set(mAverager.process(bins)); + } + + if(mWaterfallActive && !mWaterfallPausedFlag) + { + mWaterfallRows.offer(toWaterfallRow(bins)); + + while(mWaterfallRows.size() > WATERFALL_QUEUE_LIMIT) + { + mWaterfallRows.poll(); + } + } + } + + /** + * Stops rendering and releases the layers and listeners. + */ + public void dispose() + { + mDisposed = true; + mAnimationTimer.stop(); + + for(SpectralDisplayLayer layer: mLayers) + { + layer.detach(); + layer.dispose(); + } + + mLayers.clear(); + mLatestSpectrum.set(null); + mWaterfallRows.clear(); + mWaterfallTiles = null; + mWaterfallIndexes = null; + } + + /** + * Converts the DFT results to a row of waterfall intensity indexes (0 - 255). Each bin is scaled relative to the + * average bin value so the noise floor stays at the bottom of the palette regardless of tuner gain. + */ + private static byte[] toWaterfallRow(float[] bins) + { + double sum = 0.0; + + for(float bin: bins) + { + sum += bin; + } + + float average = (float)(sum / bins.length) - 1.0f; + float scale = 256.0f / average; + byte[] row = new byte[bins.length]; + + for(int x = 0; x < bins.length; x++) + { + int value = (int)((average - bins[x]) * scale); + row[x] = (byte)Math.max(0, Math.min(255, value)); + } + + return row; + } + + /** + * Updates the split pane to show the enabled sections. + */ + private void updateSections() + { + boolean spectrum = mSpectrumEnabled.get(); + boolean waterfall = mWaterfallEnabled.get(); + mSpectrumActive = spectrum; + mWaterfallActive = waterfall; + + List items = new ArrayList<>(); + + if(spectrum) + { + items.add(mSpectrumPane); + } + else + { + mLatestSpectrum.set(null); + mSpectrum = null; + } + + if(waterfall) + { + items.add(mWaterfallPane); + } + else + { + mWaterfallRows.clear(); + } + + mSplitPane.getItems().setAll(items); + mSplitPane.setVisible(!items.isEmpty()); + + if(items.size() == 2) + { + mSplitPane.setDividerPositions(mDividerPosition.get()); + } + + if(mCursor != null && ((mCursor.section() == DisplaySection.SPECTRUM && !spectrum) || + (mCursor.section() == DisplaySection.WATERFALL && !waterfall))) + { + mCursor = null; + } + + requestRender(); + } + + /** + * Called each JavaFX pulse to move new data to the display and redraw when needed. + */ + private void pulse() + { + boolean render = mRenderRequested.getAndSet(false); + + if(mClearRequested.getAndSet(false)) + { + mSpectrum = null; + + if(mWaterfallTiles != null) + { + mWaterfallTiles = null; + mWaterfallIndexes = null; + mWaterfallWriteRow = 0; + } + + render = true; + } + + SpectrumAverager.Frame spectrum = mLatestSpectrum.getAndSet(null); + + if(spectrum != null) + { + mSpectrum = spectrum; + mModel.setBinCount(spectrum.average().length); + render = true; + } + + byte[] row; + boolean waterfallUpdated = false; + + while((row = mWaterfallRows.poll()) != null) + { + if(!mWaterfallPaused.get()) + { + mModel.setBinCount(row.length); + writeWaterfallRow(row); + waterfallUpdated = true; + } + } + + if(render || waterfallUpdated) + { + renderSpectrum(); + renderWaterfall(); + } + } + + /** + * Writes the row into the waterfall ring buffer. The newest row is at mWaterfallWriteRow and older rows follow + * it, wrapping around the end of the image. The intensity indexes are kept alongside the image so the history + * can be recolored when the theme changes. + */ + private void writeWaterfallRow(byte[] row) + { + int rows = getWaterfallRowsNeeded(); + + if(mWaterfallTiles == null || mWaterfallWidth != row.length) + { + mWaterfallIndexes = new byte[row.length * rows]; + createWaterfallTiles(row.length, rows); + mWaterfallWriteRow = 0; + repaintWaterfallImage(); + } + else if(mWaterfallHeight < rows) + { + growWaterfall(rows); + } + + int width = row.length; + mWaterfallWriteRow = (mWaterfallWriteRow - 1 + mWaterfallHeight) % mWaterfallHeight; + System.arraycopy(row, 0, mWaterfallIndexes, mWaterfallWriteRow * width, width); + writeWaterfallPixels(mWaterfallWriteRow); + } + + /** + * Creates the waterfall image tiles, each at most WATERFALL_TILE_WIDTH wide. + */ + private void createWaterfallTiles(int width, int height) + { + int count = (width + WATERFALL_TILE_WIDTH - 1) / WATERFALL_TILE_WIDTH; + mWaterfallTiles = new WritableImage[count]; + + for(int tile = 0; tile < count; tile++) + { + int tileWidth = Math.min(WATERFALL_TILE_WIDTH, width - (tile * WATERFALL_TILE_WIDTH)); + mWaterfallTiles[tile] = new WritableImage(tileWidth, height); + } + + mWaterfallWidth = width; + mWaterfallHeight = height; + } + + /** + * Colors one row of the waterfall image tiles from the stored intensity indexes. + */ + private void writeWaterfallPixels(int imageRow) + { + int width = mWaterfallWidth; + + if(mWaterfallPixels == null || mWaterfallPixels.length != width) + { + mWaterfallPixels = new int[width]; + } + + SpectralDisplayTheme theme = getTheme(); + int offset = imageRow * width; + + for(int x = 0; x < width; x++) + { + mWaterfallPixels[x] = theme.getWaterfallColor(mWaterfallIndexes[offset + x]); + } + + for(int tile = 0; tile < mWaterfallTiles.length; tile++) + { + WritableImage image = mWaterfallTiles[tile]; + mWaterfallTiles[tile].getPixelWriter().setPixels(0, imageRow, (int)image.getWidth(), 1, + PixelFormat.getIntArgbInstance(), mWaterfallPixels, tile * WATERFALL_TILE_WIDTH, width); + } + } + + /** + * Recolors the whole waterfall image from the stored intensity indexes using the current theme. + */ + private void repaintWaterfallImage() + { + if(mWaterfallTiles != null && mWaterfallIndexes != null) + { + for(int y = 0; y < mWaterfallHeight; y++) + { + writeWaterfallPixels(y); + } + + requestRender(); + } + } + + /** + * Number of waterfall history rows to keep, based on the height of the waterfall section. + */ + private int getWaterfallRowsNeeded() + { + int rows = (int)Math.ceil(mWaterfallPane.getHeight()); + return Math.max(WATERFALL_MIN_ROWS, Math.min(WATERFALL_MAX_ROWS, rows)); + } + + /** + * Replaces the waterfall with a taller one and copies the history in newest to oldest order. + */ + private void growWaterfall(int rows) + { + int width = mWaterfallWidth; + int previousHeight = mWaterfallHeight; + byte[] indexes = new byte[width * rows]; + + for(int y = 0; y < previousHeight; y++) + { + int source = (mWaterfallWriteRow + y) % previousHeight; + System.arraycopy(mWaterfallIndexes, source * width, indexes, y * width, width); + } + + mWaterfallIndexes = indexes; + createWaterfallTiles(width, rows); + mWaterfallWriteRow = 0; + repaintWaterfallImage(); + } + + /** + * Height of the spectrum plot area above the inset reserved by the layers. + */ + private double getSpectrumInset() + { + double inset = 0; + + for(SpectralDisplayLayer layer: mLayers) + { + inset = Math.max(inset, layer.getSpectrumBottomInset()); + } + + return inset; + } + + private void renderSpectrum() + { + if(!mSpectrumEnabled.get()) + { + return; + } + + Canvas canvas = mSpectrumPane.getCanvas(); + double width = canvas.getWidth(); + double height = canvas.getHeight(); + + if(width < 1 || height < 1) + { + return; + } + + GraphicsContext gc = canvas.getGraphicsContext2D(); + double plotHeight = Math.max(0, height - getSpectrumInset()); + RenderArea area = new RenderArea(width, height, plotHeight); + + gc.setFill(getTheme().getBackground()); + gc.fillRect(0, 0, width, height); + + for(SpectralDisplayLayer layer: mLayers) + { + gc.save(); + layer.renderSpectrumBackground(gc, area); + gc.restore(); + } + + if(mSpectrum != null && mModel.hasSource() && plotHeight > 0) + { + renderTrace(gc, area, mSpectrum.average()); + + if(mSpectrum.peak() != null && mPeakHold.get()) + { + renderPeak(gc, area, mSpectrum.peak()); + } + } + + gc.setStroke(getTheme().getLine()); + gc.setLineWidth(1.0); + gc.strokeLine(0, Math.floor(plotHeight) + 0.5, width, Math.floor(plotHeight) + 0.5); + + for(SpectralDisplayLayer layer: mLayers) + { + gc.save(); + layer.renderSpectrumForeground(gc, area); + gc.restore(); + } + + renderZoomIndicator(gc, 8, 8); + renderMessage(gc, area); + } + + /** + * Draws the filled spectrum trace. + */ + private void renderTrace(GraphicsContext gc, RenderArea area, float[] bins) + { + double width = area.width(); + double plotHeight = area.plotHeight(); + int count = buildTracePoints(bins, width, plotHeight); + + LinearGradient gradient = new LinearGradient(0, plotHeight / 2.0, 0, area.height(), false, + CycleMethod.NO_CYCLE, new Stop(0, getTheme().getGradientTop()), + new Stop(1, getTheme().getGradientBottom())); + + gc.save(); + gc.beginPath(); + gc.rect(0, 0, width, plotHeight); + gc.clip(); + gc.setFill(gradient); + gc.fillPolygon(mPolygonX, mPolygonY, count); + gc.setStroke(getTheme().getTrace()); + gc.setLineWidth(1.0); + strokeTracePoints(gc, count); + gc.restore(); + } + + /** + * Draws the peak hold trace as a line. + */ + private void renderPeak(GraphicsContext gc, RenderArea area, float[] bins) + { + double width = area.width(); + double plotHeight = area.plotHeight(); + int count = buildTracePoints(bins, width, plotHeight); + Color cursor = getTheme().getCursor(); + + gc.save(); + gc.beginPath(); + gc.rect(0, 0, width, plotHeight); + gc.clip(); + gc.setStroke(Color.color(cursor.getRed(), cursor.getGreen(), cursor.getBlue(), 0.75)); + gc.setLineWidth(1.0); + strokeTracePoints(gc, count); + gc.restore(); + } + + /** + * Strokes the trace points, skipping the two base points so the line only follows the trace. + */ + private void strokeTracePoints(GraphicsContext gc, int count) + { + gc.beginPath(); + gc.moveTo(mPolygonX[1], mPolygonY[1]); + + for(int x = 2; x < count - 1; x++) + { + gc.lineTo(mPolygonX[x], mPolygonY[x]); + } + + gc.stroke(); + } + + /** + * Fills the polygon point arrays with the trace for the visible bins, starting and ending with points on the + * bottom of the plot. When there are more visible bins than pixels, each pixel column uses the largest bin value + * in that column so narrow signals stay visible. + * @return number of points + */ + private int buildTracePoints(float[] bins, double width, double plotHeight) + { + double scale = plotHeight / mModel.getDynamicRange(); + double offset = mModel.getZoomOffset(); + int visible = mModel.getVisibleBinCount(); + double pixelsPerBin = mModel.getPixelsPerBin(width); + int count; + + if(pixelsPerBin >= 1.0) + { + int first = Math.max(0, (int)Math.floor(offset) - 1); + int last = Math.min(bins.length - 1, (int)Math.ceil(offset + visible) + 1); + ensurePolygonCapacity(last - first + 5); + count = 0; + mPolygonX[count] = 0; + mPolygonY[count++] = plotHeight; + + for(int bin = first; bin <= last; bin++) + { + mPolygonX[count] = (bin - offset + 0.5) * pixelsPerBin; + mPolygonY[count++] = getY(bins[bin], scale, plotHeight); + } + } + else + { + int columns = (int)Math.ceil(width); + ensurePolygonCapacity(columns + 5); + count = 0; + mPolygonX[count] = 0; + mPolygonY[count++] = plotHeight; + double binsPerPixel = 1.0 / pixelsPerBin; + + for(int column = 0; column < columns; column++) + { + int start = (int)Math.floor(offset + (column * binsPerPixel)); + int end = (int)Math.floor(offset + ((column + 1) * binsPerPixel)); + start = Math.max(0, Math.min(bins.length - 1, start)); + end = Math.max(start + 1, Math.min(bins.length, end)); + float max = bins[start]; + + for(int bin = start + 1; bin < end; bin++) + { + if(bins[bin] > max) + { + max = bins[bin]; + } + } + + mPolygonX[count] = column + 0.5; + mPolygonY[count++] = getY(max, scale, plotHeight); + } + } + + mPolygonX[count] = width; + mPolygonY[count++] = plotHeight; + return count; + } + + private static double getY(float decibels, double scale, double plotHeight) + { + double y = -decibels * scale; + return Math.max(0, Math.min(plotHeight, y)); + } + + private void ensurePolygonCapacity(int size) + { + if(mPolygonX.length < size) + { + mPolygonX = new double[size]; + mPolygonY = new double[size]; + } + } + + private void renderWaterfall() + { + if(!mWaterfallEnabled.get()) + { + return; + } + + Canvas canvas = mWaterfallPane.getCanvas(); + double width = canvas.getWidth(); + double height = canvas.getHeight(); + + if(width < 1 || height < 1) + { + return; + } + + GraphicsContext gc = canvas.getGraphicsContext2D(); + gc.setFill(getTheme().getWaterfallBackground()); + gc.fillRect(0, 0, width, height); + + WritableImage[] tiles = mWaterfallTiles; + + if(tiles != null && mModel.hasSource() && mWaterfallWidth == mModel.getBinCount()) + { + double visibleStart = mModel.getZoomOffset(); + double visibleEnd = visibleStart + mModel.getVisibleBinCount(); + double pixelsPerBin = mModel.getPixelsPerBin(width); + gc.setImageSmoothing(pixelsPerBin < 1.0); + + //Newest rows run from the write row to the bottom of the image, then older rows wrap to the top + double firstRows = Math.min(mWaterfallHeight - mWaterfallWriteRow, height); + double secondRows = Math.min(mWaterfallWriteRow, height - firstRows); + + for(int tile = 0; tile < tiles.length; tile++) + { + double tileStart = tile * WATERFALL_TILE_WIDTH; + double start = Math.max(visibleStart, tileStart); + double end = Math.min(visibleEnd, tileStart + tiles[tile].getWidth()); + + if(start >= end) + { + continue; + } + + double sourceX = start - tileStart; + double sourceWidth = end - start; + double destinationX = (start - visibleStart) * pixelsPerBin; + double destinationWidth = ((end - visibleStart) * pixelsPerBin) - destinationX; + + gc.drawImage(tiles[tile], sourceX, mWaterfallWriteRow, sourceWidth, firstRows, destinationX, 0, + destinationWidth, firstRows); + + if(secondRows > 0) + { + gc.drawImage(tiles[tile], sourceX, 0, sourceWidth, secondRows, destinationX, firstRows, + destinationWidth, secondRows); + } + } + } + + RenderArea area = new RenderArea(width, height, height); + + for(SpectralDisplayLayer layer: mLayers) + { + gc.save(); + layer.renderWaterfall(gc, area); + gc.restore(); + } + + if(mWaterfallPaused.get()) + { + gc.setFill(getTheme().getCursor()); + gc.setFont(MESSAGE_FONT); + gc.setTextAlign(TextAlignment.CENTER); + gc.setTextBaseline(VPos.TOP); + gc.fillText("PAUSED", width / 2.0, 8); + } + + if(!mSpectrumEnabled.get()) + { + renderZoomIndicator(gc, 8, 8); + renderMessage(gc, area); + } + } + + /** + * Draws a small overview bar that shows the zoom window position within the full bandwidth. + */ + private void renderZoomIndicator(GraphicsContext gc, double x, double y) + { + if(mModel.getZoom() == 0 || y < 0) + { + return; + } + + double width = 80; + double height = 8; + double windowX = x + (mModel.getZoomOffset() / mModel.getBinCount() * width); + double windowWidth = Math.max(2, width / mModel.getZoomMultiplier()); + + gc.setFill(getTheme().getBackground(0.7)); + gc.fillRoundRect(x - 4, y - 4, width + 68, height + 8, 6, 6); + gc.setStroke(getTheme().getLine()); + gc.setFill(getTheme().getCursor()); + gc.setLineWidth(1.0); + gc.strokeRect(x + 0.5, y + 0.5, width, height); + gc.fillRect(windowX + 0.5, y + 0.5, windowWidth, height); + gc.setFill(getTheme().getLine()); + gc.setFont(ZOOM_FONT); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.CENTER); + gc.fillText("Zoom " + mModel.getZoomMultiplier() + "x", x + width + 6, y + (height / 2.0) + 0.5); + } + + private void renderMessage(GraphicsContext gc, RenderArea area) + { + String message = mMessage.get(); + + if(message != null && !message.isEmpty()) + { + gc.setFill(getTheme().getCursor()); + gc.setFont(MESSAGE_FONT); + gc.setTextAlign(TextAlignment.CENTER); + gc.setTextBaseline(VPos.CENTER); + gc.fillText(message, area.width() / 2.0, area.plotHeight() / 2.0); + } + } + + /** + * Updates the mouse cursor position. + */ + private void updateCursor(DisplaySection section, double x, double y, double width) + { + mCursor = new DisplayCursor(section, x, y, mModel.getFrequency(x, width)); + requestRender(); + } + + private void handleMouse(CanvasPane pane, MouseEvent event) + { + double width = pane.getWidth(); + + if(event.getEventType() == MouseEvent.MOUSE_MOVED) + { + updateCursor(pane.getSection(), event.getX(), event.getY(), width); + } + else if(event.getEventType() == MouseEvent.MOUSE_EXITED) + { + if(!mDragging) + { + mCursor = null; + requestRender(); + } + } + else if(event.getEventType() == MouseEvent.MOUSE_PRESSED) + { + if(mContextMenu != null) + { + mContextMenu.hide(); + } + + if(event.getButton() == MouseButton.PRIMARY) + { + mDragStartX = event.getX(); + mDragStartOffset = mModel.getZoomOffset(); + } + } + else if(event.getEventType() == MouseEvent.MOUSE_DRAGGED) + { + if(event.getButton() == MouseButton.PRIMARY && mModel.getZoom() > 0) + { + mDragging = true; + pane.setCursor(Cursor.CLOSED_HAND); + double bins = (mDragStartX - event.getX()) / mModel.getPixelsPerBin(width); + mModel.setZoomOffset(mDragStartOffset + bins); + } + + updateCursor(pane.getSection(), event.getX(), event.getY(), width); + } + else if(event.getEventType() == MouseEvent.MOUSE_RELEASED) + { + if(mDragging) + { + mDragging = false; + pane.setCursor(Cursor.CROSSHAIR); + } + } + } + + private void handleScroll(CanvasPane pane, ScrollEvent event) + { + double notch = event.getMultiplierY() > 0 ? event.getMultiplierY() : 40.0; + mScrollAccumulator += event.getDeltaY(); + int steps = (int)(mScrollAccumulator / notch); + + if(steps != 0) + { + mScrollAccumulator -= steps * notch; + double width = pane.getWidth(); + long frequency = mModel.getFrequency(event.getX(), width); + mModel.setZoom(mModel.getZoom() + steps, frequency, width > 0 ? event.getX() / width : 0.5); + updateCursor(pane.getSection(), event.getX(), event.getY(), width); + } + + event.consume(); + } + + private void showContextMenu(CanvasPane pane, ContextMenuEvent event) + { + if(mContextMenu != null) + { + mContextMenu.hide(); + } + + double width = pane.getWidth(); + long frequency = mModel.getFrequency(event.getX(), width); + double xFraction = width > 0 ? event.getX() / width : 0.5; + ContextMenu menu = new ContextMenu(); + + for(SpectralDisplayLayer layer: mLayers) + { + List items = layer.getContextMenuItems(pane.getSection(), frequency); + + if(items != null && !items.isEmpty()) + { + menu.getItems().addAll(items); + menu.getItems().add(new SeparatorMenuItem()); + } + } + + Menu zoomMenu = new Menu("Zoom"); + ToggleGroup zoomGroup = new ToggleGroup(); + + for(int zoom = SpectralDisplayModel.MIN_ZOOM; zoom <= SpectralDisplayModel.MAX_ZOOM; zoom++) + { + final int level = zoom; + RadioMenuItem item = new RadioMenuItem((1 << zoom) + "x"); + item.setToggleGroup(zoomGroup); + item.setSelected(mModel.getZoom() == zoom); + item.setOnAction(e -> mModel.setZoom(level, frequency, xFraction)); + zoomMenu.getItems().add(item); + } + + menu.getItems().add(zoomMenu); + + if(mWaterfallEnabled.get()) + { + CheckMenuItem pause = new CheckMenuItem("Pause Waterfall"); + pause.setSelected(mWaterfallPaused.get()); + pause.setOnAction(e -> mWaterfallPaused.set(pause.isSelected())); + menu.getItems().add(pause); + } + + mContextMenu = menu; + menu.show(pane, event.getScreenX(), event.getScreenY()); + event.consume(); + } + + /** + * Resizable pane that holds a canvas for one display section. + */ + private class CanvasPane extends Pane + { + private final Canvas mCanvas = new Canvas(); + private final DisplaySection mSection; + + CanvasPane(DisplaySection section) + { + mSection = section; + getChildren().add(mCanvas); + setMinSize(0, 20); + setPrefSize(400, 150); + setCursor(Cursor.CROSSHAIR); + addEventHandler(MouseEvent.ANY, event -> handleMouse(this, event)); + addEventHandler(ScrollEvent.SCROLL, event -> handleScroll(this, event)); + addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> showContextMenu(this, event)); + } + + Canvas getCanvas() + { + return mCanvas; + } + + DisplaySection getSection() + { + return mSection; + } + + @Override + protected void layoutChildren() + { + double width = Math.floor(getWidth()); + double height = Math.floor(getHeight()); + + if(mCanvas.getWidth() != width || mCanvas.getHeight() != height) + { + mCanvas.setWidth(width); + mCanvas.setHeight(height); + requestRender(); + } + } + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayConfigPanel.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayConfigPanel.java new file mode 100644 index 00000000..26d77b0d --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayConfigPanel.java @@ -0,0 +1,596 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import io.github.dsheirer.dsp.window.WindowType; +import io.github.dsheirer.source.tuner.Tuner; +import io.github.dsheirer.spectrum.DFTSize; +import java.net.URL; +import java.util.List; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.MenuButton; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Slider; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.util.Duration; +import javafx.util.StringConverter; +import jiconfont.icons.font_awesome.FontAwesome; +import jiconfont.javafx.IconFontFX; +import jiconfont.javafx.IconNode; +import org.controlsfx.control.ToggleSwitch; + +/** + * Translucent configuration panel that floats over the right side of the spectral display. It shows as a small + * settings tab while collapsed and expands while the mouse is over it. The pin button keeps it expanded. + * + * Controls: tuner selection, spectrum and waterfall on/off switches, waterfall pause, DFT size, window, frame rate, + * averaging, peak hold, the settings of each display layer that has settings, and the color theme. + */ +public class SpectralDisplayConfigPanel extends VBox +{ + private static final String STYLESHEET = "/spectral_display.css"; + private static final double PANEL_WIDTH = 360; + private static final double SWITCH_WIDTH = 40; + private static final java.text.DecimalFormat RESOLUTION_HZ_FORMAT = new java.text.DecimalFormat("0.#"); + private static final java.text.DecimalFormat RESOLUTION_KHZ_FORMAT = new java.text.DecimalFormat("0.##"); + private static final Duration COLLAPSE_DELAY = Duration.millis(500); + private static final Duration FADE_DURATION = Duration.millis(150); + + static + { + IconFontFX.register(FontAwesome.getIconFont()); + } + + private final DftSpectralDisplay mHost; + private final TunerSpectralDisplay mTunerSpectralDisplay; + private final SpectralDisplay mSpectralDisplay; + private final BooleanProperty mExpanded = new SimpleBooleanProperty(false); + private final BooleanProperty mPinned = new SimpleBooleanProperty(false); + private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY); + private final IconNode mTabIcon = new IconNode(FontAwesome.SLIDERS); + private final VBox mExpandedContent = new VBox(); + private final MenuButton mTunerMenuButton = new MenuButton(); + private int mPopupsShowing = 0; + + /** + * Constructs an instance. + * @param host display to configure. A tuner display adds a tuner menu to the panel. + */ + public SpectralDisplayConfigPanel(DftSpectralDisplay host) + { + mHost = host; + mTunerSpectralDisplay = host instanceof TunerSpectralDisplay tunerDisplay ? tunerDisplay : null; + mSpectralDisplay = host.getSpectralDisplay(); + + URL stylesheet = SpectralDisplayConfigPanel.class.getResource(STYLESHEET); + + if(stylesheet != null) + { + getStylesheets().add(stylesheet.toExternalForm()); + } + + getStyleClass().add("spectral-config-panel"); + setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + setFillWidth(true); + + mTabIcon.setIconSize(16); + mTabIcon.getStyleClass().add("spectral-config-icon"); + + buildExpandedContent(); + + mCollapseTimer.setOnFinished(e -> { + if(!isHover() && mPopupsShowing == 0 && !mPinned.get()) + { + mExpanded.set(false); + } + }); + + setOnMouseEntered(e -> { + mCollapseTimer.stop(); + mExpanded.set(true); + }); + setOnMouseExited(e -> scheduleCollapse()); + mExpanded.addListener((ob, ov, nv) -> updateExpanded()); + mPinned.addListener((ob, ov, nv) -> { + if(nv) + { + mExpanded.set(true); + } + else + { + scheduleCollapse(); + } + }); + + updateExpanded(); + } + + /** + * Keeps the panel expanded when true. + */ + public BooleanProperty pinnedProperty() + { + return mPinned; + } + + /** + * Indicates if the panel is expanded. + */ + public BooleanProperty expandedProperty() + { + return mExpanded; + } + + /** + * Releases resources. + */ + public void dispose() + { + mCollapseTimer.stop(); + } + + private void scheduleCollapse() + { + if(!mPinned.get()) + { + mCollapseTimer.playFromStart(); + } + } + + /** + * Keeps the panel expanded while a combo box, menu or color picker popup is showing, since the mouse leaves the + * panel to use the popup. + */ + private void trackPopup(ReadOnlyBooleanProperty showing) + { + showing.addListener((ob, ov, nv) -> { + mPopupsShowing = Math.max(0, mPopupsShowing + (nv ? 1 : -1)); + + if(!nv) + { + scheduleCollapse(); + } + }); + } + + private void updateExpanded() + { + boolean expanded = mExpanded.get() || mPinned.get(); + + if(expanded) + { + getChildren().setAll(mExpandedContent); + getStyleClass().remove("collapsed"); + setPrefWidth(PANEL_WIDTH); + mExpandedContent.setOpacity(0); + FadeTransition fade = new FadeTransition(FADE_DURATION, mExpandedContent); + fade.setToValue(1.0); + fade.play(); + } + else + { + getChildren().setAll(mTabIcon); + if(!getStyleClass().contains("collapsed")) + { + getStyleClass().add("collapsed"); + } + setPrefWidth(Region.USE_COMPUTED_SIZE); + } + } + + private void buildExpandedContent() + { + //Header: icon, title and pin button + IconNode headerIcon = new IconNode(FontAwesome.SLIDERS); + headerIcon.setIconSize(14); + headerIcon.getStyleClass().add("spectral-config-icon"); + Label title = new Label("Display Settings"); + title.getStyleClass().add("spectral-config-title"); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + IconNode pinIcon = new IconNode(FontAwesome.THUMB_TACK); + pinIcon.setIconSize(12); + pinIcon.getStyleClass().add("spectral-config-icon"); + ToggleButton pinButton = new ToggleButton(null, pinIcon); + pinButton.getStyleClass().add("spectral-config-pin"); + pinButton.selectedProperty().bindBidirectional(mPinned); + pinButton.setTooltip(new Tooltip("Keep the settings panel open")); + HBox header = new HBox(6, headerIcon, title, spacer, pinButton); + header.setAlignment(Pos.CENTER_LEFT); + header.setPadding(new Insets(2, 2, 4, 4)); + + GridPane grid = new GridPane(); + grid.setHgap(8); + grid.setVgap(5); + grid.setPadding(new Insets(0, 8, 6, 6)); + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setHalignment(HPos.RIGHT); + //Labels keep their full width - the controls column absorbs any shortfall + labelColumn.setMinWidth(Region.USE_PREF_SIZE); + ColumnConstraints controlColumn = new ColumnConstraints(); + controlColumn.setHgrow(Priority.ALWAYS); + controlColumn.setFillWidth(true); + grid.getColumnConstraints().addAll(labelColumn, controlColumn); + int row = 0; + + //Tuner + if(mTunerSpectralDisplay != null) + { + grid.add(sectionLabel("Source"), 0, row++, 2, 1); + mTunerMenuButton.setMaxWidth(Double.MAX_VALUE); + mTunerMenuButton.setOnShowing(e -> updateTunerMenu()); + trackPopup(mTunerMenuButton.showingProperty()); + mTunerMenuButton.textProperty().bind(Bindings.createStringBinding(() -> { + Tuner tuner = mTunerSpectralDisplay.displayedTunerProperty().get(); + return tuner != null ? tuner.getPreferredName() : "None"; + }, mTunerSpectralDisplay.displayedTunerProperty())); + row = addRow(grid, row, "Tuner", mTunerMenuButton); + } + + //Display sections + grid.add(sectionLabel("Display"), 0, row++, 2, 1); + ToggleSwitch spectrumSwitch = toggleSwitch("Spectrum", mSpectralDisplay.spectrumEnabledProperty()); + ToggleSwitch waterfallSwitch = toggleSwitch("Waterfall", mSpectralDisplay.waterfallEnabledProperty()); + ToggleSwitch pauseSwitch = toggleSwitch("Pause", mSpectralDisplay.waterfallPausedProperty()); + pauseSwitch.disableProperty().bind(mSpectralDisplay.waterfallEnabledProperty().not()); + pauseSwitch.setTooltip(new Tooltip("Pause the waterfall")); + HBox switches = new HBox(12, labeled(spectrumSwitch), labeled(waterfallSwitch), labeled(pauseSwitch)); + switches.setAlignment(Pos.CENTER); + grid.add(switches, 0, row++, 2, 1); + + ComboBox theme = comboBox(SpectralDisplayTheme.values()); + theme.valueProperty().bindBidirectional(mSpectralDisplay.themeProperty()); + theme.setCellFactory(listView -> new ThemeCell()); + theme.setButtonCell(new ThemeCell()); + row = addRow(grid, row, "Theme", theme); + + //Layer settings without a section of their own + for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers()) + { + if(layer.getConfigurationTitle() == null) + { + row = addLayerRows(grid, row, layer.getConfigurationRows()); + } + } + + //DFT + grid.add(sectionLabel("FFT"), 0, row++, 2, 1); + ComboBox dftSize = comboBox(DFTSize.values()); + dftSize.setConverter(dftSizeConverter()); + //Refresh the resolution shown with each size when the sample rate changes + mSpectralDisplay.getModel().sampleRateProperty().addListener((ob, ov, nv) -> + dftSize.setConverter(dftSizeConverter())); + dftSize.setTooltip(new Tooltip("FFT size and the resulting frequency resolution (bandwidth of each bin)")); + dftSize.valueProperty().bindBidirectional(mHost.dftSizeProperty()); + row = addRow(grid, row, "Size", dftSize); + + ComboBox windowType = comboBox(java.util.Arrays.stream(WindowType.values()) + .filter(DftSpectralDisplay::isWindowSupported).toArray(WindowType[]::new)); + windowType.valueProperty().bindBidirectional(mHost.windowTypeProperty()); + row = addRow(grid, row, "Window", windowType); + + ComboBox frameRate = new ComboBox<>(); + for(int rate: DftSpectralDisplay.FRAME_RATES) + { + frameRate.getItems().add(rate); + } + if(!frameRate.getItems().contains(mHost.frameRateProperty().get())) + { + frameRate.getItems().add(mHost.frameRateProperty().get()); + frameRate.getItems().sort(Integer::compare); + } + frameRate.setMaxWidth(Double.MAX_VALUE); + frameRate.setConverter(converter(rate -> rate + " fps")); + frameRate.setValue(mHost.frameRateProperty().get()); + frameRate.valueProperty().addListener((ob, ov, nv) -> { + if(nv != null) + { + mHost.frameRateProperty().set(nv); + } + }); + trackPopup(frameRate.showingProperty()); + row = addRow(grid, row, "Frame Rate", frameRate); + + Slider averaging = new Slider(SpectralDisplay.AVERAGING_MINIMUM, SpectralDisplay.AVERAGING_MAXIMUM, + mSpectralDisplay.averagingProperty().get()); + averaging.setBlockIncrement(1); + averaging.setMajorTickUnit(1); + averaging.setMinorTickCount(0); + averaging.setSnapToTicks(true); + averaging.valueProperty().addListener((ob, ov, nv) -> + mSpectralDisplay.averagingProperty().set((int)Math.round(nv.doubleValue()))); + HBox.setHgrow(averaging, Priority.ALWAYS); + Label fast = new Label("Fast"); + fast.getStyleClass().add("spectral-config-hint"); + Label slow = new Label("Slow"); + slow.getStyleClass().add("spectral-config-hint"); + HBox averagingBox = new HBox(4, fast, averaging, slow); + averagingBox.setAlignment(Pos.CENTER_LEFT); + Tooltip.install(averagingBox, new Tooltip("Spectrum averaging: signals rise quickly and fade slowly. " + + "Slower settings give a steadier noise floor.")); + row = addRow(grid, row, "Averaging", averagingBox); + + ConfigurationRow peakHold = ConfigurationRow.toggle("Peak Hold", mSpectralDisplay.peakHoldProperty()); + Tooltip.install(peakHold.control(), new Tooltip("Show a line at the recent peak of each frequency, " + + "falling slowly")); + row = addRow(grid, row, peakHold.label(), peakHold.control()); + + //Layer settings + for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers()) + { + List rows = layer.getConfigurationRows(); + + if(layer.getConfigurationTitle() != null && rows != null && !rows.isEmpty()) + { + grid.add(sectionLabel(layer.getConfigurationTitle()), 0, row++, 2, 1); + row = addLayerRows(grid, row, rows); + } + } + + VBox content = new VBox(4, grid); + content.setPadding(new Insets(0, 0, 4, 0)); + + ScrollPane scrollPane = new ScrollPane(content); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scrollPane.getStyleClass().add("spectral-config-scroll"); + + //Limit the panel to the display height and scroll the controls when they don't fit + scrollPane.prefViewportHeightProperty().bind(content.heightProperty()); + scrollPane.maxHeightProperty().bind(Bindings.max(40, mHost.heightProperty() + .subtract(header.heightProperty()).subtract(24))); + + mExpandedContent.getChildren().addAll(header, scrollPane); + } + + /** + * Converter that labels each DFT size with its bin bandwidth at the current sample rate. + */ + private StringConverter dftSizeConverter() + { + return converter(size -> { + int sampleRate = mSpectralDisplay.getModel().getSampleRate(); + + if(sampleRate <= 0) + { + return size.getLabel(); + } + + return size.getLabel() + " \u00B7 " + formatResolution((double)sampleRate / size.getSize()); + }); + } + + /** + * Formats a bin bandwidth in Hz or kHz. + */ + static String formatResolution(double hertz) + { + if(hertz >= 1000.0) + { + return RESOLUTION_KHZ_FORMAT.format(hertz / 1000.0) + " kHz/bin"; + } + + return RESOLUTION_HZ_FORMAT.format(hertz) + " Hz/bin"; + } + + /** + * Rebuilds the tuner menu with the currently available tuners. + */ + private void updateTunerMenu() + { + mTunerMenuButton.getItems().clear(); + ToggleGroup group = new ToggleGroup(); + Tuner current = mTunerSpectralDisplay.displayedTunerProperty().get(); + List tuners = mTunerSpectralDisplay.getAvailableTuners(); + + for(Tuner tuner: tuners) + { + RadioMenuItem item = new RadioMenuItem(tuner.getPreferredName()); + item.setToggleGroup(group); + item.setSelected(tuner == current); + item.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(tuner)); + mTunerMenuButton.getItems().add(item); + } + + if(!tuners.isEmpty()) + { + mTunerMenuButton.getItems().add(new SeparatorMenuItem()); + } + + RadioMenuItem none = new RadioMenuItem("None"); + none.setToggleGroup(group); + none.setSelected(current == null); + none.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(null)); + mTunerMenuButton.getItems().add(none); + } + + /** + * Adds a layer's configuration rows to the grid. + * @return next row index + */ + private int addLayerRows(GridPane grid, int row, List rows) + { + if(rows != null) + { + for(ConfigurationRow configurationRow: rows) + { + if(configurationRow.control() instanceof ComboBox comboBox) + { + trackPopup(comboBox.showingProperty()); + } + + row = addRow(grid, row, configurationRow.label(), configurationRow.control()); + } + } + + return row; + } + + private int addRow(GridPane grid, int row, String label, Node control) + { + grid.add(new Label(label), 0, row); + grid.add(control, 1, row); + GridPane.setHgrow(control, Priority.ALWAYS); + return row + 1; + } + + private static Label sectionLabel(String text) + { + Label label = new Label(text); + label.getStyleClass().add("spectral-config-section"); + return label; + } + + /** + * Places the switch's text in a label to the left of the switch. The switch's own text adds a wide gap. + */ + private static HBox labeled(ToggleSwitch toggleSwitch) + { + Label label = new Label(toggleSwitch.getText()); + label.setMinWidth(Region.USE_PREF_SIZE); + label.disableProperty().bind(toggleSwitch.disabledProperty()); + label.setOnMouseClicked(e -> { + if(!toggleSwitch.isDisabled()) + { + toggleSwitch.setSelected(!toggleSwitch.isSelected()); + } + }); + toggleSwitch.setText(null); + //The switch skin reserves space for its text even when there is none, so size it to the thumb track + toggleSwitch.setMinWidth(SWITCH_WIDTH); + toggleSwitch.setPrefWidth(SWITCH_WIDTH); + toggleSwitch.setMaxWidth(SWITCH_WIDTH); + HBox box = new HBox(4, label, toggleSwitch); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static ToggleSwitch toggleSwitch(String text, BooleanProperty property) + { + ToggleSwitch toggleSwitch = new ToggleSwitch(text); + toggleSwitch.selectedProperty().bindBidirectional(property); + return toggleSwitch; + } + + @SafeVarargs + private ComboBox comboBox(T... values) + { + ComboBox comboBox = new ComboBox<>(); + comboBox.getItems().addAll(values); + comboBox.setMaxWidth(Double.MAX_VALUE); + trackPopup(comboBox.showingProperty()); + return comboBox; + } + + private static StringConverter converter(java.util.function.Function function) + { + return new StringConverter<>() + { + @Override + public String toString(T value) + { + return value != null ? function.apply(value) : ""; + } + + @Override + public T fromString(String string) + { + return null; + } + }; + } + + /** + * Theme list cell with a preview swatch: the spectrum background, a channel highlight and the waterfall palette. + */ + private static class ThemeCell extends javafx.scene.control.ListCell + { + private static final double SWATCH_WIDTH = 44; + private static final double SWATCH_HEIGHT = 12; + + @Override + protected void updateItem(SpectralDisplayTheme theme, boolean empty) + { + super.updateItem(theme, empty); + + if(empty || theme == null) + { + setText(null); + setGraphic(null); + return; + } + + setText(theme.getLabel()); + setGraphic(createSwatch(theme)); + setGraphicTextGap(8); + } + + private static Node createSwatch(SpectralDisplayTheme theme) + { + javafx.scene.canvas.Canvas canvas = new javafx.scene.canvas.Canvas(SWATCH_WIDTH, SWATCH_HEIGHT); + javafx.scene.canvas.GraphicsContext gc = canvas.getGraphicsContext2D(); + double half = SWATCH_WIDTH / 2.0; + + //Left half: spectrum background, trace fill and a processing channel highlight + gc.setFill(theme.getBackground()); + gc.fillRect(0, 0, half, SWATCH_HEIGHT); + gc.setFill(theme.getChannelProcessing()); + gc.fillRect(half * 0.55, 0, half * 0.3, SWATCH_HEIGHT); + gc.setFill(theme.getGradientBottom()); + gc.fillRect(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.35); + gc.setStroke(theme.getTrace()); + gc.strokeLine(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.65); + + //Right half: waterfall palette from the noise floor to the strongest signal + for(int x = 0; x < half; x++) + { + int index = (int)Math.round(x / (half - 1) * (WaterfallPalette.SIZE - 1)); + gc.setFill(WaterfallPalette.toColor(theme.getWaterfallColor(index))); + gc.fillRect(half + x, 0, 1, SWATCH_HEIGHT); + } + + gc.setStroke(javafx.scene.paint.Color.gray(0.5, 0.8)); + gc.strokeRect(0.5, 0.5, SWATCH_WIDTH - 1, SWATCH_HEIGHT - 1); + return canvas; + } + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java new file mode 100644 index 00000000..1d0b429c --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java @@ -0,0 +1,162 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import java.util.Collections; +import java.util.List; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.MenuItem; + +/** + * Optional layer that draws on the spectral display. The display calls the render methods on the JavaFX application + * thread each time it redraws, in the order the layers were added. Override only the methods the layer needs. + * + * Spectrum drawing order: background color, each layer's background, spectrum trace, each layer's foreground. + * Waterfall drawing order: waterfall image, each layer's waterfall overlay. + */ +public abstract class SpectralDisplayLayer +{ + private SpectralDisplay mSpectralDisplay; + + /** + * Called by the display when the layer is added. + */ + void attach(SpectralDisplay spectralDisplay) + { + mSpectralDisplay = spectralDisplay; + attached(); + } + + /** + * Called by the display when the layer is removed. + */ + void detach() + { + detached(); + mSpectralDisplay = null; + } + + /** + * Hook for subclasses after the layer is added to a display. + */ + protected void attached() + { + } + + /** + * Hook for subclasses before the layer is removed from a display. + */ + protected void detached() + { + } + + /** + * Display this layer is added to, or null. + */ + protected SpectralDisplay getSpectralDisplay() + { + return mSpectralDisplay; + } + + /** + * Frequency model of the display this layer is added to, or null. + */ + protected SpectralDisplayModel getModel() + { + return mSpectralDisplay != null ? mSpectralDisplay.getModel() : null; + } + + /** + * Asks the display to redraw on the next frame. Safe to call from any thread. + */ + protected void requestRender() + { + if(mSpectralDisplay != null) + { + mSpectralDisplay.requestRender(); + } + } + + /** + * Pixels this layer needs along the bottom of the spectrum section, below the spectrum plot. The display reserves + * the largest inset that any layer asks for. + */ + public double getSpectrumBottomInset() + { + return 0; + } + + /** + * Draws behind the spectrum trace. + */ + public void renderSpectrumBackground(GraphicsContext gc, RenderArea area) + { + } + + /** + * Draws on top of the spectrum trace. + */ + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + } + + /** + * Draws on top of the waterfall. + */ + public void renderWaterfall(GraphicsContext gc, RenderArea area) + { + } + + /** + * Context menu items for a right-click at the frequency. + * @param section that was clicked + * @param frequency under the mouse + * @return menu items, or an empty list + */ + public List getContextMenuItems(DisplaySection section, long frequency) + { + return Collections.emptyList(); + } + + /** + * Title of this layer's section in the configuration panel, or null to place the layer's rows in the panel's + * Display section instead of a section of their own. + */ + public String getConfigurationTitle() + { + return getClass().getSimpleName(); + } + + /** + * Labeled controls for this layer's section of the configuration panel, or an empty list if the layer has no + * settings. + */ + public List getConfigurationRows() + { + return Collections.emptyList(); + } + + /** + * Releases resources and listeners. + */ + public void dispose() + { + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModel.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModel.java new file mode 100644 index 00000000..1c263fac --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModel.java @@ -0,0 +1,316 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; + +/** + * Frequency and zoom state shared by the spectrum, the waterfall and the display layers, with the conversions + * between frequency, DFT bin and screen position. Access this model only on the JavaFX application thread. + * + * DFT bins are in display order (after the half swap) so bin 0 is the lowest frequency and bin (size / 2) is the + * center frequency. The center of bin i is at minimum frequency + (i * bin width). The display shows the bins + * [zoom offset, zoom offset + visible bin count) and each visible bin covers an equal share of the screen width. + */ +public class SpectralDisplayModel +{ + public static final int MIN_ZOOM = 0; + public static final int MAX_ZOOM = 6; + + private final LongProperty mCenterFrequency = new SimpleLongProperty(0); + private final IntegerProperty mSampleRate = new SimpleIntegerProperty(0); + private final IntegerProperty mBinCount = new SimpleIntegerProperty(4096); + private final IntegerProperty mZoom = new SimpleIntegerProperty(MIN_ZOOM); + private final DoubleProperty mZoomOffset = new SimpleDoubleProperty(0); + private final DoubleProperty mSampleSize = new SimpleDoubleProperty(16.0); + private final DoubleProperty mNoiseBandwidth = new SimpleDoubleProperty(0); + + /** + * Constructs an instance. + */ + public SpectralDisplayModel() + { + } + + /** + * Center frequency of the source in Hertz. + */ + public LongProperty centerFrequencyProperty() + { + return mCenterFrequency; + } + + public long getCenterFrequency() + { + return mCenterFrequency.get(); + } + + /** + * Sample rate (bandwidth) of the source in Hertz. + */ + public IntegerProperty sampleRateProperty() + { + return mSampleRate; + } + + public int getSampleRate() + { + return mSampleRate.get(); + } + + /** + * Number of DFT bins in each result set. + */ + public IntegerProperty binCountProperty() + { + return mBinCount; + } + + public int getBinCount() + { + return mBinCount.get(); + } + + /** + * Sets the bin count and keeps the same frequency at the left edge of the zoom window. + */ + public void setBinCount(int binCount) + { + if(binCount > 0 && binCount != getBinCount()) + { + double ratio = getZoomOffset() / (double)getBinCount(); + mBinCount.set(binCount); + setZoomOffset(ratio * binCount); + } + } + + /** + * Zoom level where the zoom multiplier is 2 ^ zoom. + */ + public IntegerProperty zoomProperty() + { + return mZoom; + } + + public int getZoom() + { + return mZoom.get(); + } + + public int getZoomMultiplier() + { + return 1 << getZoom(); + } + + /** + * Index of the first DFT bin shown at the left edge of the display. + */ + public DoubleProperty zoomOffsetProperty() + { + return mZoomOffset; + } + + public double getZoomOffset() + { + return mZoomOffset.get(); + } + + /** + * Sets the zoom window offset, limited so the zoom window stays inside the DFT bins. + */ + public void setZoomOffset(double offset) + { + double max = getBinCount() - getVisibleBinCount(); + mZoomOffset.set(Math.max(0, Math.min(max, offset))); + } + + /** + * Source sample size in bits, which sets the dB range of the spectrum plot. + */ + public DoubleProperty sampleSizeProperty() + { + return mSampleSize; + } + + /** + * Equivalent noise bandwidth of the DFT window, in bins, for dBFS data. When set, the spectrum plot extends + * below the sample dynamic range by the DFT processing gain, since the noise in each bin is that much lower than + * the total noise. Zero (the default) for data that doesn't include the processing gain. + */ + public DoubleProperty noiseBandwidthProperty() + { + return mNoiseBandwidth; + } + + /** + * DFT processing gain in dB: 10 * log10(bin count / window noise bandwidth), or zero when the noise bandwidth is + * not set. + */ + public double getProcessingGain() + { + double noiseBandwidth = mNoiseBandwidth.get(); + return noiseBandwidth > 0 ? 10.0 * Math.log10(getBinCount() / noiseBandwidth) : 0.0; + } + + /** + * Range of the spectrum plot in dB below full scale. The bottom of the plot is at minus this value. The range is + * the dynamic range of the source sample size plus the DFT processing gain. + */ + public double getDynamicRange() + { + return 20.0 * Math.log10(Math.pow(2.0, mSampleSize.get() - 1.0)) + getProcessingGain(); + } + + /** + * Indicates if a source frequency and sample rate are set. + */ + public boolean hasSource() + { + return getSampleRate() > 0; + } + + /** + * Number of DFT bins in the zoom window. + */ + public int getVisibleBinCount() + { + return Math.max(1, getBinCount() / getZoomMultiplier()); + } + + /** + * Width of each DFT bin in Hertz. + */ + public double getBinWidth() + { + return (double)getSampleRate() / (double)getBinCount(); + } + + /** + * Lowest frequency of the source. + */ + public long getMinFrequency() + { + return getCenterFrequency() - (getSampleRate() / 2); + } + + /** + * Highest frequency of the source. + */ + public long getMaxFrequency() + { + return getCenterFrequency() + (getSampleRate() / 2); + } + + /** + * Frequency at the left edge of the display. + */ + public double getDisplayMinFrequency() + { + return getMinFrequency() + ((getZoomOffset() - 0.5) * getBinWidth()); + } + + /** + * Frequency at the right edge of the display. + */ + public double getDisplayMaxFrequency() + { + return getDisplayMinFrequency() + getDisplayBandwidth(); + } + + /** + * Bandwidth shown across the display, in Hertz. + */ + public double getDisplayBandwidth() + { + return getVisibleBinCount() * getBinWidth(); + } + + /** + * Screen x position of the frequency. + * @param frequency in Hertz + * @param width of the display in pixels + */ + public double getX(double frequency, double width) + { + double bandwidth = getDisplayBandwidth(); + + if(bandwidth <= 0) + { + return 0; + } + + return (frequency - getDisplayMinFrequency()) / bandwidth * width; + } + + /** + * Frequency at the screen x position. + * @param x position in pixels + * @param width of the display in pixels + */ + public long getFrequency(double x, double width) + { + if(width <= 0) + { + return getCenterFrequency(); + } + + return Math.round(getDisplayMinFrequency() + (x / width * getDisplayBandwidth())); + } + + /** + * Pixel width of one DFT bin at the current zoom. + */ + public double getPixelsPerBin(double width) + { + return width / getVisibleBinCount(); + } + + /** + * Changes the zoom level and keeps the frequency at the same screen position. + * @param zoom level, limited to MIN_ZOOM - MAX_ZOOM + * @param frequency to hold in place + * @param xFraction screen position of the frequency as a fraction (0 - 1.0) of the display width + */ + public void setZoom(int zoom, long frequency, double xFraction) + { + zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + + if(zoom != getZoom()) + { + double bin = hasSource() ? ((frequency - getMinFrequency()) / getBinWidth()) + 0.5 : getBinCount() / 2.0; + mZoom.set(zoom); + setZoomOffset(bin - (xFraction * getVisibleBinCount())); + } + } + + /** + * Resets the zoom to the full source bandwidth. + */ + public void resetZoom() + { + mZoom.set(MIN_ZOOM); + setZoomOffset(0); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayPanel.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayPanel.java new file mode 100644 index 00000000..afa1e620 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayPanel.java @@ -0,0 +1,190 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import io.github.dsheirer.gui.theme.ThemeManager; +import io.github.dsheirer.playlist.PlaylistManager; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.source.tuner.Tuner; +import io.github.dsheirer.source.tuner.TunerEvent; +import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel; +import io.github.dsheirer.util.ThreadPool; +import java.awt.EventQueue; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; + +/** + * Swing panel that holds the JavaFX tuner spectral display for the main application window. + * + * The JavaFX display is created on the JavaFX application thread after construction. A tuner requested before the + * display exists is shown once the display is ready. + */ +public class SpectralDisplayPanel extends JFXPanel +{ + private final DiscoveredTunerModel mDiscoveredTunerModel; + private TunerSpectralDisplay mTunerSpectralDisplay; + private Tuner mPendingTuner; + private boolean mDisposed; + + /** + * Constructs an instance. + * @param playlistManager for the channels + * @param discoveredTunerModel for the list of tuners + */ + public SpectralDisplayPanel(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel) + { + mDiscoveredTunerModel = discoveredTunerModel; + Platform.setImplicitExit(false); + + Platform.runLater(() -> { + TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, true); + display.setTunerSelectionHandler(this::tunerSelected); + Scene scene = new Scene(display); + ThemeManager.getInstance().register(scene); + setScene(scene); + + Tuner pending; + + synchronized(SpectralDisplayPanel.this) + { + if(mDisposed) + { + ThreadPool.CACHED.submit(display::dispose); + return; + } + + mTunerSpectralDisplay = display; + pending = mPendingTuner; + mPendingTuner = null; + } + + if(pending != null) + { + ThreadPool.CACHED.submit(() -> display.showTuner(pending)); + } + }); + } + + /** + * Handles a tuner selection in the configuration panel. Selecting a tuner turns the main spectral display on and + * selecting no tuner turns it off, the same as the View menu. + */ + private void tunerSelected(Tuner tuner) + { + //Broadcast on the Swing event thread, the same as the Swing tuner menu items, since listeners update Swing + EventQueue.invokeLater(() -> { + if(tuner != null) + { + SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true); + mDiscoveredTunerModel.broadcast(new TunerEvent(tuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY)); + } + else + { + SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false); + mDiscoveredTunerModel.broadcast(new TunerEvent(getTuner(), + TunerEvent.Event.REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY)); + } + }); + } + + /** + * Shows the tuner in the display. Can block while the tuner starts its sample stream. + */ + public void showTuner(Tuner tuner) + { + TunerSpectralDisplay display; + + synchronized(this) + { + if(mTunerSpectralDisplay == null) + { + mPendingTuner = tuner; + return; + } + + display = mTunerSpectralDisplay; + } + + display.showTuner(tuner); + } + + /** + * Stops showing the current tuner. + */ + public void clearTuner() + { + TunerSpectralDisplay display; + + synchronized(this) + { + if(mTunerSpectralDisplay == null) + { + mPendingTuner = null; + return; + } + + display = mTunerSpectralDisplay; + } + + display.clearTuner(); + } + + /** + * Currently displayed tuner, or null. + */ + public Tuner getTuner() + { + TunerSpectralDisplay display; + + synchronized(this) + { + if(mTunerSpectralDisplay == null) + { + return mPendingTuner; + } + + display = mTunerSpectralDisplay; + } + + return display.getTuner(); + } + + /** + * Stops showing the tuner and releases resources. + */ + public void dispose() + { + TunerSpectralDisplay display; + + synchronized(this) + { + mDisposed = true; + display = mTunerSpectralDisplay; + mTunerSpectralDisplay = null; + mPendingTuner = null; + } + + if(display != null) + { + display.dispose(); + } + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayTheme.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayTheme.java new file mode 100644 index 00000000..3f0c00a6 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayTheme.java @@ -0,0 +1,263 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import javafx.scene.paint.Color; + +/** + * Color themes for the spectral display. Each theme sets every display color and the waterfall palette, so the user + * picks one theme instead of setting each color. + * + * Color blind safe themes use perceptually uniform waterfall palettes (viridis, cividis, inferno) that keep an even + * brightness ramp for each type of color vision deficiency, and Okabe-Ito colors for the channel highlights, which + * also differ in brightness so they don't rely on hue alone. + */ +public enum SpectralDisplayTheme +{ + CLASSIC("Classic", Group.STANDARD, + "#000000", "#FFFFFF80", "#00FF0080", "#FFFFFF80", "#C0C0C080", "#FFC800", + "#C0C0C03C", "#00FF003C", "#0000FF3C", "#FFFF00", + WaterfallPalette.classic()), + + DARK("Dark", Group.STANDARD, + "#121417", "#64B5F68C", "#1565C059", "#90CAF9E6", "#8A939ECC", "#FFCA28", + "#9AA4B138", "#26A69A59", "#7E57C273", "#FFEE58", + WaterfallPalette.gradient("#0B0E24", "#152A6B", "#1F5FA8", "#2BA3B8", "#7BD389", "#F2E35B", "#F28C38", + "#E8443A")), + + MEDIUM("Medium", Group.STANDARD, + "#3A3F47", "#E0E6EE99", "#8FA8C866", "#F0F4F8E6", "#C4CCD6CC", "#FFB74D", + "#C4CCD640", "#66BB6A66", "#42A5F573", "#FFF176", + WaterfallPalette.gradient("#2A2F38", "#31527A", "#3C8DAD", "#7BC8A4", "#F2D06B", "#F08A4B", "#E04F5F")), + + LIGHT("Light", Group.STANDARD, + "#F7F8FA", "#1E88E559", "#90CAF940", "#1565C0", "#5F6B7AE6", "#D84315", + "#607D8B2E", "#43A0474D", "#1E88E54D", "#E65100", + WaterfallPalette.gradient("#FFFFFF", "#DCEBF7", "#9CC9E8", "#4F9BD0", "#1F6AA5", "#0B3C73", "#061A40")), + + GREEN_PHOSPHOR("Green Phosphor", Group.MONOCHROME, + "#000A00", "#33FF6673", "#00661A59", "#66FF8C", "#2E9E4AD9", "#CCFFCC", + "#1F7A354D", "#33FF664D", "#B3FFC666", "#CCFFCC", + WaterfallPalette.gradient("#000A00", "#00290A", "#005C1A", "#00A332", "#33FF66", "#CCFFD9")), + + AMBER("Amber", Group.MONOCHROME, + "#0D0700", "#FFB00073", "#7A4A0059", "#FFC233", "#B07A10D9", "#FFF0C2", + "#8A5A004D", "#FFB00047", "#FFE08A66", "#FFF0C2", + WaterfallPalette.gradient("#0D0700", "#2E1A00", "#5C3600", "#A66300", "#FFB000", "#FFE6A6")), + + GRAYSCALE("Grayscale", Group.MONOCHROME, + "#000000", "#FFFFFF80", "#60606066", "#FFFFFFD9", "#A0A0A0D9", "#FFFFFF", + "#80808040", "#C0C0C04D", "#FFFFFF66", "#FFFFFF", + WaterfallPalette.gradient("#000000", "#404040", "#808080", "#C0C0C0", "#FFFFFF")), + + HIGH_CONTRAST_DARK("High Contrast Dark", Group.HIGH_CONTRAST, + "#000000", "#FFFFFFBF", "#FFFFFF59", "#FFFFFF", "#FFFFFF", "#FFFF00", + "#FFFFFF2E", "#00FF0059", "#00FFFF66", "#FF00FF", + WaterfallPalette.gradient("#000000", "#0000FF", "#00FFFF", "#FFFF00", "#FFFFFF")), + + HIGH_CONTRAST_LIGHT("High Contrast Light", Group.HIGH_CONTRAST, + "#FFFFFF", "#00000099", "#00000040", "#000000", "#000000", "#D00000", + "#0000001F", "#0080004D", "#0000FF4D", "#C000C0", + WaterfallPalette.gradient("#FFFFFF", "#FFFF00", "#FF8000", "#C00000", "#000000")), + + VIRIDIS("Viridis (red/green color blind)", Group.COLOR_BLIND, + "#0A0A12", "#FDE72573", "#21918C59", "#FDE725", "#A0A0B0D9", "#FFFFFF", + "#A0A0A038", "#0072B273", "#E69F0073", "#CC79A7", + WaterfallPalette.gradient("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779", + "#6DCD59", "#B4DE2C", "#FDE725")), + + CIVIDIS("Cividis (red/green color blind)", Group.COLOR_BLIND, + "#00122E", "#FEE83873", "#575D6D66", "#FEE838", "#A9B0BFD9", "#FFFFFF", + "#A9B0BF38", "#56B4E966", "#E69F0073", "#CC79A7", + WaterfallPalette.gradient("#00224E", "#123570", "#3B496C", "#575D6D", "#707173", "#8A8779", "#A69D75", + "#C4B56C", "#E4CF5B", "#FEE838")), + + INFERNO("Inferno (blue/yellow color blind)", Group.COLOR_BLIND, + "#000004", "#FCFFA473", "#BC375466", "#FCFFA4", "#B0A8B8D9", "#FFFFFF", + "#A0A0A038", "#D55E0073", "#56B4E973", "#FFFFFF", + WaterfallPalette.gradient("#000004", "#1B0C41", "#4A0C6B", "#781C6D", "#A52C60", "#CF4446", "#ED6925", + "#FB9B06", "#F7D13D", "#FCFFA4")); + + /** + * Theme groups, for ordering and labeling in the theme selection list. + */ + public enum Group + { + STANDARD, + MONOCHROME, + HIGH_CONTRAST, + COLOR_BLIND; + } + + private final String mLabel; + private final Group mGroup; + private final Color mBackground; + private final Color mGradientTop; + private final Color mGradientBottom; + private final Color mTrace; + private final Color mLine; + private final Color mCursor; + private final Color mChannel; + private final Color mChannelProcessing; + private final Color mChannelSelected; + private final Color mCorrection; + private final int[] mWaterfallPalette; + + /** + * Constructs a theme. Colors are web color strings with an optional alpha byte (#RRGGBBAA). + * @param label to show to the user + * @param group the theme belongs to + * @param background of the spectrum + * @param gradientTop of the spectrum fill, at the middle of the spectrum height + * @param gradientBottom of the spectrum fill, at the bottom of the spectrum + * @param trace line along the top of the spectrum fill + * @param line color of the axis, grid lines and text + * @param cursor line and readout color + * @param channel fill for channels that are not processing + * @param channelProcessing fill for processing channels + * @param channelSelected fill for the selected channel + * @param correction color of the channel frequency correction marker + * @param waterfallPalette 256 ARGB colors from the noise floor to the strongest signal + */ + SpectralDisplayTheme(String label, Group group, String background, String gradientTop, String gradientBottom, + String trace, String line, String cursor, String channel, String channelProcessing, + String channelSelected, String correction, int[] waterfallPalette) + { + mLabel = label; + mGroup = group; + mBackground = Color.web(background); + mGradientTop = Color.web(gradientTop); + mGradientBottom = Color.web(gradientBottom); + mTrace = Color.web(trace); + mLine = Color.web(line); + mCursor = Color.web(cursor); + mChannel = Color.web(channel); + mChannelProcessing = Color.web(channelProcessing); + mChannelSelected = Color.web(channelSelected); + mCorrection = Color.web(correction); + mWaterfallPalette = waterfallPalette; + } + + /** + * Default theme: the original sdrtrunk spectral display colors. + */ + public static SpectralDisplayTheme getDefault() + { + return CLASSIC; + } + + public String getLabel() + { + return mLabel; + } + + public Group getGroup() + { + return mGroup; + } + + public Color getBackground() + { + return mBackground; + } + + /** + * Background color with the opacity, for readout boxes drawn over the display. + */ + public Color getBackground(double opacity) + { + return Color.color(mBackground.getRed(), mBackground.getGreen(), mBackground.getBlue(), opacity); + } + + public Color getGradientTop() + { + return mGradientTop; + } + + public Color getGradientBottom() + { + return mGradientBottom; + } + + public Color getTrace() + { + return mTrace; + } + + public Color getLine() + { + return mLine; + } + + /** + * Line color with a fraction of its opacity, for grid lines. + */ + public Color getLine(double opacityFraction) + { + return Color.color(mLine.getRed(), mLine.getGreen(), mLine.getBlue(), mLine.getOpacity() * opacityFraction); + } + + public Color getCursor() + { + return mCursor; + } + + public Color getChannel() + { + return mChannel; + } + + public Color getChannelProcessing() + { + return mChannelProcessing; + } + + public Color getChannelSelected() + { + return mChannelSelected; + } + + public Color getCorrection() + { + return mCorrection; + } + + /** + * ARGB color for the waterfall intensity index (0 - 255). + */ + public int getWaterfallColor(int index) + { + return mWaterfallPalette[index & 0xFF]; + } + + /** + * Waterfall color at the lowest intensity, which is the waterfall background. + */ + public Color getWaterfallBackground() + { + return WaterfallPalette.toColor(mWaterfallPalette[0]); + } + + @Override + public String toString() + { + return mLabel; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayWindow.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayWindow.java new file mode 100644 index 00000000..ed886c28 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayWindow.java @@ -0,0 +1,66 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import io.github.dsheirer.gui.theme.ThemeManager; +import io.github.dsheirer.playlist.PlaylistManager; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.source.tuner.Tuner; +import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel; +import io.github.dsheirer.util.ThreadPool; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * Separate window with a tuner spectral display. The window starts with the saved spectral display settings, but + * changes made in the window are not saved. + */ +public class SpectralDisplayWindow +{ + private SpectralDisplayWindow() + { + } + + /** + * Opens a window that shows the tuner. Can be called from any thread once the JavaFX toolkit is running. + */ + public static void show(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, Tuner tuner) + { + Platform.runLater(() -> { + TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, false); + Scene scene = new Scene(display, 1280, 600); + ThemeManager.getInstance().register(scene); + + Stage stage = new Stage(); + stage.setScene(scene); + String application = SystemProperties.getInstance().getApplicationName(); + stage.titleProperty().bind(Bindings.createStringBinding(() -> { + Tuner displayed = display.displayedTunerProperty().get(); + return application + " - " + (displayed != null ? displayed.getPreferredName() : "Spectral Display"); + }, display.displayedTunerProperty())); + stage.setOnHidden(e -> ThreadPool.CACHED.submit(display::dispose)); + stage.show(); + + ThreadPool.CACHED.submit(() -> display.showTuner(tuner)); + }); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectrumAverager.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectrumAverager.java new file mode 100644 index 00000000..47e0b20a --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectrumAverager.java @@ -0,0 +1,176 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +/** + * Spectrum trace averaging and peak hold. Thread safe. + * + * Averaging is an exponential moving average of each bin's linear power with separate rates for rising and falling + * values. A falling bin moves 1 / averaging of the way to the new value each frame, and a rising bin moves + * ATTACK_RATIO times faster, so signals appear quickly while the noise floor stays steady. Averaging linear power + * instead of decibels keeps the result at the true mean power. Averaging decibel values reads about 2.5 dB low for + * noise. + * + * Peak hold keeps the highest recent value of each bin from the unaveraged frames, falling PEAK_DECAY_DB per frame. + */ +public class SpectrumAverager +{ + public static final int AVERAGING_MINIMUM = 1; + public static final int AVERAGING_MAXIMUM = 20; + public static final int AVERAGING_DEFAULT = 4; + public static final double ATTACK_RATIO = 2.5; + public static final float PEAK_DECAY_DB = 0.15f; + private static final float MINIMUM_DB = -196.0f; + private static final double LN10_OVER_10 = Math.log(10.0) / 10.0; + + private int mAveraging = AVERAGING_DEFAULT; + private boolean mPeakHold = false; + private boolean mResetPeak = false; + //Double precision so the average can fall more than 70 dB in one frame without float rounding error + private double[] mAveragedPower; + private float[] mPeakBins; + + /** + * Processed spectrum for one DFT frame. + * @param average bins in dB after averaging + * @param peak hold bins in dB, or null when peak hold is off + */ + public record Frame(float[] average, float[] peak) + { + } + + /** + * Sets the averaging, from AVERAGING_MINIMUM (no averaging) to AVERAGING_MAXIMUM (slowest). + */ + public synchronized void setAveraging(int averaging) + { + mAveraging = Math.max(AVERAGING_MINIMUM, Math.min(AVERAGING_MAXIMUM, averaging)); + } + + /** + * Turns peak hold on or off. Turning it on starts from the next frame. + */ + public synchronized void setPeakHold(boolean peakHold) + { + mPeakHold = peakHold; + mResetPeak = true; + } + + /** + * Restarts the peak hold from the next frame, for example after a frequency change. + */ + public synchronized void resetPeak() + { + mResetPeak = true; + } + + /** + * Clears the averaging history and the peak hold. + */ + public synchronized void reset() + { + mAveragedPower = null; + mPeakBins = null; + } + + /** + * Processes one frame of DFT bins in dB. + */ + public synchronized Frame process(float[] bins) + { + int size = bins.length; + double decay = 1.0 / mAveraging; + double attack = Math.min(1.0, ATTACK_RATIO * decay); + + if(mAveragedPower == null || mAveragedPower.length != size) + { + mAveragedPower = new double[size]; + + for(int x = 0; x < size; x++) + { + mAveragedPower[x] = toPowerDouble(bins[x]); + } + } + else + { + for(int x = 0; x < size; x++) + { + double power = toPowerDouble(bins[x]); + double average = mAveragedPower[x]; + mAveragedPower[x] = average + ((power - average) * (power > average ? attack : decay)); + } + } + + float[] averaged = new float[size]; + + for(int x = 0; x < size; x++) + { + averaged[x] = mAveragedPower[x] > 0 ? + Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(mAveragedPower[x]))) : MINIMUM_DB; + } + + float[] peak = null; + + if(mPeakHold) + { + if(mPeakBins == null || mPeakBins.length != size || mResetPeak) + { + mResetPeak = false; + mPeakBins = bins.clone(); + } + else + { + for(int x = 0; x < size; x++) + { + mPeakBins[x] = Math.max(bins[x], mPeakBins[x] - PEAK_DECAY_DB); + } + } + + peak = mPeakBins.clone(); + } + else + { + mPeakBins = null; + } + + return new Frame(averaged, peak); + } + + /** + * Converts decibels to linear power. + */ + public static float toPower(float decibels) + { + return (float)Math.exp(decibels * LN10_OVER_10); + } + + private static double toPowerDouble(float decibels) + { + return Math.exp(decibels * LN10_OVER_10); + } + + /** + * Converts linear power to decibels. + */ + public static float toDecibels(float power) + { + return power > 0 ? Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(power))) : MINIMUM_DB; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java b/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java new file mode 100644 index 00000000..b2180994 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java @@ -0,0 +1,354 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import io.github.dsheirer.controller.channel.ChannelModel; +import io.github.dsheirer.controller.channel.ChannelProcessingManager; +import io.github.dsheirer.gui.spectrum.layer.ChannelLayer; +import io.github.dsheirer.playlist.PlaylistManager; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.source.ISourceEventProcessor; +import io.github.dsheirer.source.SourceEvent; +import io.github.dsheirer.source.tuner.Tuner; +import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; +import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel; +import io.github.dsheirer.util.ThreadPool; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Spectral display for a tuner: the spectral display with the decibel, frequency and playlist channel layers, the DFT + * processing for the tuner's sample stream, and the collapsible configuration panel with a tuner menu. + * + * Construct on the JavaFX application thread. showTuner() and clearTuner() can be called from any thread, but they + * can block while the tuner starts or stops its sample stream, so don't call them on the JavaFX application thread. + */ +public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceEventProcessor +{ + private static final Logger LOGGER = LoggerFactory.getLogger(TunerSpectralDisplay.class); + + /** + * Indicates if the main window spectral display shows a tuner. Cleared when the user turns the display off. + */ + public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled"; + public static final String SETTINGS_PREFIX = "spectral.display"; + private static final String CHANNEL_DISPLAY = "channel.display"; + private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details"; + private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings"; + + private final ChannelLayer mChannelLayer; + private final DiscoveredTunerModel mDiscoveredTunerModel; + private final Object mTunerLock = new Object(); + private final ReadOnlyObjectWrapper mDisplayedTuner = new ReadOnlyObjectWrapper<>(); + private Consumer mTunerSelectionHandler; + private Tuner mTuner; + private boolean mProcessing; + + /** + * Constructs an instance. + * @param playlistManager for the channels + * @param discoveredTunerModel for the list of tuners + * @param saveSettings true to save setting changes, false to load the saved settings but not change them + */ + public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, + boolean saveSettings) + { + this(playlistManager != null ? playlistManager.getChannelModel() : null, + playlistManager != null ? playlistManager.getChannelProcessingManager() : null, + discoveredTunerModel, saveSettings); + } + + /** + * Constructs an instance. + * @param channelModel for the channels, or null + * @param channelProcessingManager for channel processing events, or null + * @param discoveredTunerModel for the list of tuners, or null + * @param saveSettings true to save setting changes, false to load the saved settings but not change them + */ + public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager, + DiscoveredTunerModel discoveredTunerModel, boolean saveSettings) + { + this(new ChannelLayer(channelModel, channelProcessingManager), discoveredTunerModel, saveSettings); + } + + private TunerSpectralDisplay(ChannelLayer channelLayer, DiscoveredTunerModel discoveredTunerModel, + boolean saveSettings) + { + super(SETTINGS_PREFIX, saveSettings, channelLayer); + mChannelLayer = channelLayer; + mDiscoveredTunerModel = discoveredTunerModel; + getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER); + + SystemProperties properties = SystemProperties.getInstance(); + mChannelLayer.channelDisplayProperty().set(parse(ChannelLayer.ChannelDisplay.class, + properties.get(key(CHANNEL_DISPLAY), ChannelLayer.ChannelDisplay.ALL.name()), + ChannelLayer.ChannelDisplay.ALL)); + mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true)); + mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name())); + mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv)); + + installConfigPanel(); + } + + /** + * Tuner shown in the display, updated on the JavaFX application thread. + */ + public ReadOnlyObjectProperty displayedTunerProperty() + { + return mDisplayedTuner.getReadOnlyProperty(); + } + + /** + * Tuners that can be shown. + */ + public List getAvailableTuners() + { + List tuners = new ArrayList<>(); + + if(mDiscoveredTunerModel != null) + { + for(DiscoveredTuner discoveredTuner: mDiscoveredTunerModel.getAvailableTuners()) + { + if(discoveredTuner.hasTuner()) + { + tuners.add(discoveredTuner.getTuner()); + } + } + } + + return tuners; + } + + /** + * Sets the handler for tuner selections in the configuration panel. The default handler shows the selected tuner. + * @param handler receives the selected tuner, or null when the user selects no tuner. Called on the JavaFX thread. + */ + public void setTunerSelectionHandler(Consumer handler) + { + mTunerSelectionHandler = handler; + } + + /** + * Called by the configuration panel on the JavaFX application thread when the user selects a tuner, or null. + */ + void tunerSelected(Tuner tuner) + { + if(mTunerSelectionHandler != null) + { + mTunerSelectionHandler.accept(tuner); + } + else + { + ThreadPool.CACHED.submit(() -> { + if(tuner != null) + { + showTuner(tuner); + } + else + { + clearTuner(); + } + }); + } + } + + /** + * Currently displayed tuner, or null. + */ + public Tuner getTuner() + { + synchronized(mTunerLock) + { + return mTuner; + } + } + + /** + * Shows the tuner in the display and stops showing the previous tuner. + */ + public void showTuner(Tuner tuner) + { + synchronized(mTunerLock) + { + if(tuner == mTuner) + { + return; + } + + clearTuner(); + + if(tuner == null) + { + return; + } + + mTuner = tuner; + tuner.getTunerController().addListener(this); + long frequency = tuner.getTunerController().getFrequency(); + int sampleRate = (int)tuner.getTunerController().getSampleRate(); + double sampleSize = tuner.getSampleSize(); + + runOnFxThread(() -> { + if(getTuner() == tuner) + { + SpectralDisplayModel model = getSpectralDisplay().getModel(); + model.sampleSizeProperty().set(sampleSize); + model.centerFrequencyProperty().set(frequency); + model.sampleRateProperty().set(sampleRate); + getSpectralDisplay().messageProperty().set(null); + mDisplayedTuner.set(tuner); + } + }); + + updateProcessing(); + } + } + + /** + * Stops showing the current tuner. + */ + public void clearTuner() + { + synchronized(mTunerLock) + { + Tuner tuner = mTuner; + + if(tuner != null) + { + stopProcessing(); + tuner.getTunerController().removeListener(this); + mTuner = null; + } + + getSpectralDisplay().clear(); + + runOnFxThread(() -> { + if(getTuner() == null) + { + SpectralDisplayModel model = getSpectralDisplay().getModel(); + model.sampleRateProperty().set(0); + model.centerFrequencyProperty().set(0); + model.resetZoom(); + getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER); + mDisplayedTuner.set(null); + } + }); + } + } + + @Override + protected void processingEnabledChanged() + { + ThreadPool.CACHED.submit(() -> { + synchronized(mTunerLock) + { + updateProcessing(); + } + }); + } + + /** + * Starts or stops processing the tuner sample stream. Hold the tuner lock when calling. + */ + private void updateProcessing() + { + if(mTuner != null && isProcessingEnabled()) + { + startProcessing(); + } + else + { + stopProcessing(); + } + } + + private void startProcessing() + { + if(!mProcessing && mTuner != null) + { + mProcessing = true; + startDft(); + + try + { + mTuner.getTunerController().addBufferListener(getDftProcessor()); + } + catch(Exception e) + { + LOGGER.error("Error registering spectral display for tuner sample buffers", e); + } + } + } + + private void stopProcessing() + { + if(mProcessing) + { + mProcessing = false; + + if(mTuner != null) + { + mTuner.getTunerController().removeBufferListener(getDftProcessor()); + } + + stopDft(); + } + } + + /** + * Receives frequency and sample rate changes from the tuner. + */ + @Override + public void process(SourceEvent event) + { + switch(event.getEvent()) + { + case NOTIFICATION_FREQUENCY_CHANGE -> + { + long frequency = event.getValue().longValue(); + runOnFxThread(() -> getSpectralDisplay().getModel().centerFrequencyProperty().set(frequency)); + } + case NOTIFICATION_SAMPLE_RATE_CHANGE -> + { + int sampleRate = event.getValue().intValue(); + runOnFxThread(() -> getSpectralDisplay().getModel().sampleRateProperty().set(sampleRate)); + } + default -> + { + } + } + } + + /** + * Stops showing the tuner and releases resources. Call from a thread other than the JavaFX application thread + * when a tuner is showing, since stopping the sample stream can block. + */ + @Override + public void dispose() + { + clearTuner(); + super.dispose(); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/WaterfallPalette.java b/src/main/java/io/github/dsheirer/gui/spectrum/WaterfallPalette.java new file mode 100644 index 00000000..d60b4d7d --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/WaterfallPalette.java @@ -0,0 +1,126 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum; + +import javafx.scene.paint.Color; + +/** + * Builds waterfall color palettes that map an intensity index (0 - 255) to an opaque ARGB color. + */ +public class WaterfallPalette +{ + public static final int SIZE = 256; + + private WaterfallPalette() + { + } + + /** + * Palette of the original Swing waterfall: dark blue noise floor, a blue to yellow ramp, yellow fading to orange, + * then red. + */ + public static int[] classic() + { + int[] palette = new int[SIZE]; + + for(int x = 0; x < 16; x++) + { + palette[x] = argb(0, 0, 127); + } + + for(int x = 16; x < 32; x++) + { + palette[x] = argb(0, 0, 191); + } + + int r = 0; + int g = 0; + int b = 191; + + for(int x = 32; x < 60; x++) + { + palette[x] = argb(r, g, b); + r += 9; + g += 9; + b -= 6; + } + + g = 255; + + for(int x = 60; x < 188; x++) + { + palette[x] = argb(255, g, 0); + g -= 2; + } + + for(int x = 188; x < SIZE; x++) + { + palette[x] = argb(255, 0, 0); + } + + return palette; + } + + /** + * Palette that blends linearly between evenly spaced colors. + * @param colors web color strings (for example #440154), lowest intensity first + */ + public static int[] gradient(String... colors) + { + if(colors.length < 2) + { + throw new IllegalArgumentException("A gradient palette needs at least two colors"); + } + + Color[] stops = new Color[colors.length]; + + for(int x = 0; x < colors.length; x++) + { + stops[x] = Color.web(colors[x]); + } + + int[] palette = new int[SIZE]; + int segments = stops.length - 1; + + for(int x = 0; x < SIZE; x++) + { + double position = (double)x / (SIZE - 1) * segments; + int segment = Math.min(segments - 1, (int)Math.floor(position)); + Color color = stops[segment].interpolate(stops[segment + 1], position - segment); + palette[x] = argb((int)Math.round(color.getRed() * 255), (int)Math.round(color.getGreen() * 255), + (int)Math.round(color.getBlue() * 255)); + } + + return palette; + } + + /** + * JavaFX color for an ARGB palette entry. + */ + public static Color toColor(int argb) + { + return Color.rgb((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF); + } + + private static int argb(int r, int g, int b) + { + return 0xFF000000 | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java new file mode 100644 index 00000000..c8a3a33f --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java @@ -0,0 +1,744 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum.layer; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.controller.channel.ChannelEvent; +import io.github.dsheirer.controller.channel.ChannelModel; +import io.github.dsheirer.controller.channel.ChannelProcessingManager; +import io.github.dsheirer.eventbus.MyEventBus; +import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest; +import io.github.dsheirer.gui.spectrum.ConfigurationRow; +import io.github.dsheirer.gui.spectrum.DisplayCursor; +import io.github.dsheirer.gui.spectrum.DisplaySection; +import io.github.dsheirer.gui.spectrum.RenderArea; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; +import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; +import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.source.tuner.channel.TunerChannel; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.ConcurrentModificationException; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.ComboBox; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Tooltip; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +/** + * Playlist channel layer. Draws each playlist channel, enabled (processing) or disabled, and each active traffic + * channel (tracked from the channel processing start and stop events) as a translucent box at its frequency and bandwidth, labeled with the system, site, channel name and + * decoder. Processing and selected channels use their own colors. When the decoder applies a frequency correction, + * a bracket at the bottom of the box shows the corrected channel position and the PPM error. + * + * When the zoom level is too low for a channel's labels to fit in its box, moving the mouse over the channel shows a + * popout card near the bottom of the spectrum (or the waterfall) with the channel name, system, site, decoder, + * frequency and state. The popout can be turned off with the hover details setting. + * + * Right-clicking a channel adds a menu item to view or edit the channel in the playlist editor. + */ +public class ChannelLayer extends SpectralDisplayLayer +{ + private static final Font FONT = Font.font(11); + private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.0"); + private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000"); + private static final Font POPOUT_TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11); + private static final double MIN_LABEL_BOX_WIDTH = 12; + private static final double POPOUT_MIN_HIT_WIDTH = 10; + private static final double POPOUT_PADDING = 6; + private static final double POPOUT_GAP = 5; + private static final double POPOUT_SWATCH = 8; + private static final int POPOUT_MAX_CHANNELS = 4; + + /** + * Which channels the layer shows. + */ + public enum ChannelDisplay + { + ALL("All Channels"), + ENABLED("Enabled Channels"), + NONE("No Channels"); + + private final String mLabel; + + ChannelDisplay(String label) + { + mLabel = label; + } + + @Override + public String toString() + { + return mLabel; + } + } + + private final ChannelModel mChannelModel; + private final ChannelProcessingManager mChannelProcessingManager; + private final Listener mChannelEventListener = this::receive; + //Active traffic channels. Traffic channel managers keep their temporary channels out of the channel model, so + //they are tracked from the processing start and stop events. + private final Set mTrafficChannels = ConcurrentHashMap.newKeySet(); + private final ObjectProperty mChannelDisplay = new SimpleObjectProperty<>(ChannelDisplay.ALL); + private final BooleanProperty mHoverDetails = new SimpleBooleanProperty(true); + private final Text mMeasure = new Text(); + private final double mTextHeight; + private volatile boolean mChannelsChanged = true; + private List mChannels = new ArrayList<>(); + private long mQueryMinimum; + private long mQueryMaximum; + + /** + * Constructs an instance. + * @param channelModel for the playlist channels and the active traffic channels + * @param channelProcessingManager for channel processing start and stop events + */ + public ChannelLayer(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager) + { + mChannelModel = channelModel; + mChannelProcessingManager = channelProcessingManager; + mMeasure.setFont(FONT); + mMeasure.setText("Ag"); + mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight()); + mChannelDisplay.addListener((ob, ov, nv) -> requestRender()); + mHoverDetails.addListener((ob, ov, nv) -> requestRender()); + + if(mChannelModel != null) + { + mChannelModel.addListener(mChannelEventListener); + } + + if(mChannelProcessingManager != null) + { + mChannelProcessingManager.addChannelEventListener(mChannelEventListener); + + //Traffic channels that were already running before this layer was created + for(Channel channel: mChannelProcessingManager.getProcessingChannels()) + { + if(channel.isTrafficChannel()) + { + mTrafficChannels.add(channel); + } + } + } + } + + /** + * Tracks active traffic channels and refreshes the channels on any channel change. + */ + private void receive(ChannelEvent event) + { + Channel channel = event.getChannel(); + + if(channel != null && channel.isTrafficChannel()) + { + switch(event.getEvent()) + { + case NOTIFICATION_PROCESSING_START -> mTrafficChannels.add(channel); + case NOTIFICATION_PROCESSING_STOP, NOTIFICATION_PROCESSING_START_REJECTED, NOTIFICATION_DELETE -> + mTrafficChannels.remove(channel); + default -> + { + } + } + } + + channelsChanged(); + } + + /** + * Which channels to show. + */ + public ObjectProperty channelDisplayProperty() + { + return mChannelDisplay; + } + + @Override + public void dispose() + { + if(mChannelModel != null) + { + mChannelModel.removeListener(mChannelEventListener); + } + + if(mChannelProcessingManager != null) + { + mChannelProcessingManager.removeChannelEventListener(mChannelEventListener); + } + + mChannels.clear(); + mTrafficChannels.clear(); + } + + private void channelsChanged() + { + mChannelsChanged = true; + requestRender(); + } + + @Override + public String getConfigurationTitle() + { + return "Channels"; + } + + @Override + public List getConfigurationRows() + { + ComboBox comboBox = new ComboBox<>(); + comboBox.getItems().addAll(ChannelDisplay.values()); + comboBox.valueProperty().bindBidirectional(mChannelDisplay); + comboBox.setMaxWidth(Double.MAX_VALUE); + ConfigurationRow details = ConfigurationRow.toggle("Hover Details", mHoverDetails); + Tooltip.install(details.control(), new Tooltip("Show channel details under the mouse when the channel " + + "labels don't fit at the current zoom")); + return List.of(new ConfigurationRow("Show", comboBox), details); + } + + /** + * Shows a popout card with the details of the channels under the mouse when their labels don't fit. + */ + public BooleanProperty hoverDetailsProperty() + { + return mHoverDetails; + } + + /** + * Channels that overlap the source bandwidth, refreshed when the channels or the source frequency change. + */ + private List getChannels(SpectralDisplayModel model) + { + long minimum = model.getMinFrequency(); + long maximum = model.getMaxFrequency(); + + if(mChannelsChanged || minimum != mQueryMinimum || maximum != mQueryMaximum) + { + try + { + mChannelsChanged = false; + List channels = mChannelModel != null ? + mChannelModel.getChannelsInFrequencyRange(minimum, maximum) : new ArrayList<>(); + + for(Channel trafficChannel: mTrafficChannels) + { + if(!channels.contains(trafficChannel) && trafficChannel.isWithin(minimum, maximum)) + { + channels.add(trafficChannel); + } + } + + mChannels = channels; + mQueryMinimum = minimum; + mQueryMaximum = maximum; + } + catch(ConcurrentModificationException cme) + { + //The channel lists changed while reading them - try again on the next frame + mChannelsChanged = true; + requestRender(); + } + } + + return mChannels; + } + + private boolean isShown(Channel channel) + { + return switch(mChannelDisplay.get()) + { + case ALL -> true; + case ENABLED -> channel.isProcessing(); + default -> false; + }; + } + + @Override + public void renderSpectrumBackground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE) + { + return; + } + + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + + for(Channel channel: getChannels(model)) + { + if(!isShown(channel)) + { + continue; + } + + Color color = channel.isSelected() ? theme.getChannelSelected() : + channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel(); + gc.setFill(color); + + for(TunerChannel tunerChannel: channel.getTunerChannels()) + { + Box box = getBox(model, tunerChannel, area); + + if(box != null) + { + gc.fillRect(box.x(), 0, box.width(), area.plotHeight()); + } + } + } + } + + @Override + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE) + { + return; + } + + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + Color line = theme.getLine(); + gc.setFont(FONT); + gc.setTextBaseline(VPos.TOP); + gc.setLineWidth(1.0); + + for(Channel channel: getChannels(model)) + { + if(!isShown(channel)) + { + continue; + } + + for(TunerChannel tunerChannel: channel.getTunerChannels()) + { + Box box = getBox(model, tunerChannel, area); + + if(box == null) + { + continue; + } + + double center = Math.floor(box.center()) + 0.5; + gc.setStroke(line); + gc.strokeLine(center, area.plotHeight() * 0.65, center, area.plotHeight() - 1); + + if(box.width() >= MIN_LABEL_BOX_WIDTH) + { + gc.save(); + gc.beginPath(); + gc.rect(box.x(), 0, box.width(), area.plotHeight()); + gc.clip(); + gc.setFill(line); + double y = 1; + y = drawLabel(gc, channel.hasSystem() ? channel.getSystem() : null, box, y); + y = drawLabel(gc, channel.hasSite() ? channel.getSite() : null, box, y); + y = drawLabel(gc, channel.getName(), box, y); + drawLabel(gc, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), box, y); + gc.restore(); + } + + int correction = channel.getChannelFrequencyCorrection(); + + if(correction != 0) + { + drawCorrection(gc, model, area, tunerChannel, box, correction); + } + } + } + + renderPopout(gc, area, DisplaySection.SPECTRUM); + } + + @Override + public void renderWaterfall(GraphicsContext gc, RenderArea area) + { + renderPopout(gc, area, DisplaySection.WATERFALL); + } + + /** + * Draws a card near the bottom of the section under the mouse with the details of each channel under the mouse + * whose labels don't fit in its box at the current zoom. The card moves to the top of the section when the mouse + * is over the bottom of the section. + */ + private void renderPopout(GraphicsContext gc, RenderArea area, DisplaySection section) + { + SpectralDisplayModel model = getModel(); + DisplayCursor cursor = getSpectralDisplay().getDisplayCursor(); + + if(!mHoverDetails.get() || model == null || !model.hasSource() || cursor == null || + cursor.section() != section || mChannelDisplay.get() == ChannelDisplay.NONE) + { + return; + } + + List entries = getPopoutEntries(model, area, cursor.x()); + + if(entries.isEmpty()) + { + return; + } + + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + int shown = Math.min(entries.size(), POPOUT_MAX_CHANNELS); + String more = entries.size() > shown ? "+ " + (entries.size() - shown) + " more" : null; + + //Measure the card + double textWidth = 0; + int lineCount = 0; + + for(int x = 0; x < shown; x++) + { + PopoutEntry entry = entries.get(x); + textWidth = Math.max(textWidth, measure(entry.title(), POPOUT_TITLE_FONT) + POPOUT_SWATCH + 5); + + for(String line: entry.details()) + { + textWidth = Math.max(textWidth, measure(line, FONT)); + } + + lineCount += 1 + entry.details().size(); + } + + if(more != null) + { + textWidth = Math.max(textWidth, measure(more, FONT)); + lineCount++; + } + + double width = textWidth + (POPOUT_PADDING * 2); + double height = (lineCount * mTextHeight) + ((shown - 1) * POPOUT_GAP) + (POPOUT_PADDING * 2); + double bottom = section == DisplaySection.SPECTRUM ? area.plotHeight() : area.height(); + double x = Math.max(4, Math.min(area.width() - width - 4, cursor.x() - (width / 2.0))); + double y = bottom - height - 6; + + //Keep the card out from under the mouse + if(cursor.y() >= y - 4) + { + y = 6; + } + + if(y < 0 || width > area.width()) + { + return; + } + + gc.setFill(theme.getBackground(0.88)); + gc.fillRoundRect(x, y, width, height, 8, 8); + gc.setStroke(opaque(theme.getLine())); + gc.setLineWidth(1.0); + gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.TOP); + + double lineY = y + POPOUT_PADDING; + double textX = x + POPOUT_PADDING; + + for(int index = 0; index < shown; index++) + { + PopoutEntry entry = entries.get(index); + double swatchY = lineY + ((mTextHeight - POPOUT_SWATCH) / 2.0); + gc.setFill(opaque(entry.color())); + gc.fillRect(textX, swatchY, POPOUT_SWATCH, POPOUT_SWATCH); + gc.setFill(theme.getCursor()); + gc.setFont(POPOUT_TITLE_FONT); + gc.fillText(entry.title(), textX + POPOUT_SWATCH + 5, lineY); + lineY += mTextHeight; + gc.setFill(opaque(theme.getLine())); + gc.setFont(FONT); + + for(String line: entry.details()) + { + gc.fillText(line, textX, lineY); + lineY += mTextHeight; + } + + lineY += POPOUT_GAP; + } + + if(more != null) + { + gc.fillText(more, textX, lineY - POPOUT_GAP); + } + } + + /** + * Channels under the screen x position whose labels don't fit in the channel box. The hit area of a narrow + * channel is widened so it can be found with the mouse. + */ + private List getPopoutEntries(SpectralDisplayModel model, RenderArea area, double cursorX) + { + List entries = new ArrayList<>(); + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + + for(Channel channel: getChannels(model)) + { + if(!isShown(channel)) + { + continue; + } + + for(TunerChannel tunerChannel: channel.getTunerChannels()) + { + Box box = getBox(model, tunerChannel, area); + + if(box == null) + { + continue; + } + + double hitWidth = Math.max(POPOUT_MIN_HIT_WIDTH, box.width()); + double hitStart = box.center() - (hitWidth / 2.0); + + if(cursorX < hitStart || cursorX > hitStart + hitWidth || labelsFit(channel, box)) + { + continue; + } + + List details = new ArrayList<>(); + String systemSite = join(channel.hasSystem() ? channel.getSystem() : null, + channel.hasSite() ? channel.getSite() : null); + + if(systemSite != null) + { + details.add(systemSite); + } + + String state = channel.isProcessing() ? "Enabled" : "Disabled"; + + if(channel.isSelected()) + { + state += ", Selected"; + } + + details.add(join(channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), + FREQUENCY_FORMAT.format(tunerChannel.getFrequency() / 1E6) + " MHz", state)); + + Color color = channel.isSelected() ? theme.getChannelSelected() : + channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel(); + entries.add(new PopoutEntry(channel.getName() != null ? channel.getName() : "(unnamed)", details, + color)); + break; + } + } + + return entries; + } + + /** + * Indicates if all of the channel labels fit inside the channel box without clipping. + */ + private boolean labelsFit(Channel channel, Box box) + { + if(box.width() < MIN_LABEL_BOX_WIDTH) + { + return false; + } + + String[] labels = {channel.hasSystem() ? channel.getSystem() : null, + channel.hasSite() ? channel.getSite() : null, channel.getName(), + channel.getDecodeConfiguration().getDecoderType().getShortDisplayString()}; + + for(String label: labels) + { + if(label != null && !label.isEmpty() && measure(label, FONT) > box.width()) + { + return false; + } + } + + return true; + } + + private double measure(String text, Font font) + { + mMeasure.setFont(font); + mMeasure.setText(text); + double width = mMeasure.getLayoutBounds().getWidth(); + mMeasure.setFont(FONT); + return width; + } + + /** + * Joins the non-empty values with a separator, or returns null when there are none. + */ + private static String join(String... values) + { + StringBuilder sb = new StringBuilder(); + + for(String value: values) + { + if(value != null && !value.isEmpty()) + { + if(!sb.isEmpty()) + { + sb.append(" \u00B7 "); + } + + sb.append(value); + } + } + + return sb.isEmpty() ? null : sb.toString(); + } + + private static Color opaque(Color color) + { + return Color.color(color.getRed(), color.getGreen(), color.getBlue()); + } + + /** + * Draws the label centered in the box, or left aligned and clipped when it is wider than the box. + * @return y position for the next label + */ + private double drawLabel(GraphicsContext gc, String text, Box box, double y) + { + if(text != null && !text.isEmpty()) + { + mMeasure.setText(text); + double width = mMeasure.getLayoutBounds().getWidth(); + + if(width > box.width()) + { + gc.setTextAlign(TextAlignment.LEFT); + gc.fillText(text, box.x() + 1, y); + } + else + { + gc.setTextAlign(TextAlignment.CENTER); + gc.fillText(text, box.center(), y); + } + } + + return y + mTextHeight; + } + + /** + * Draws a bracket at the bottom of the channel at the corrected frequency, labeled with the PPM error. + */ + private void drawCorrection(GraphicsContext gc, SpectralDisplayModel model, RenderArea area, + TunerChannel tunerChannel, Box box, int correction) + { + double top = area.plotHeight() * 0.88; + double bottom = area.plotHeight() * 0.98; + double corrected = model.getX(tunerChannel.getFrequency() + correction, area.width()); + double start = corrected - (box.width() / 2.0); + double stop = corrected + (box.width() / 2.0); + + Color correctionColor = getSpectralDisplay().getTheme().getCorrection(); + gc.setStroke(correctionColor); + gc.strokeLine(start, bottom, stop, bottom); + gc.strokeLine(start, top, start, bottom); + gc.strokeLine(stop, top, stop, bottom); + + double ppm = correction / (tunerChannel.getFrequency() / 1E6); + String label = "PPM " + PPM_FORMAT.format(ppm); + mMeasure.setText(label); + + if(mMeasure.getLayoutBounds().getWidth() <= box.width() && mTextHeight * 5 <= area.plotHeight()) + { + gc.setFill(correctionColor); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.BOTTOM); + gc.fillText(label, start + 1, bottom - 2); + gc.setTextBaseline(VPos.TOP); + } + } + + /** + * Screen position of the tuner channel, or null when it is outside the display. + */ + private static Box getBox(SpectralDisplayModel model, TunerChannel tunerChannel, RenderArea area) + { + long minimum = (long)Math.floor(model.getDisplayMinFrequency()); + long maximum = (long)Math.ceil(model.getDisplayMaxFrequency()); + + if(tunerChannel == null || !tunerChannel.overlaps(minimum, maximum)) + { + return null; + } + + double center = model.getX(tunerChannel.getFrequency(), area.width()); + double width = tunerChannel.getBandwidth() / model.getDisplayBandwidth() * area.width(); + return new Box(center - (width / 2.0), width, center); + } + + @Override + public List getContextMenuItems(DisplaySection section, long frequency) + { + List items = new ArrayList<>(); + SpectralDisplayModel model = getModel(); + + if(model == null || mChannelDisplay.get() == ChannelDisplay.NONE) + { + return items; + } + + for(Channel channel: getChannels(model)) + { + if(!isShown(channel)) + { + continue; + } + + for(TunerChannel tunerChannel: channel.getTunerChannels()) + { + if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency && + frequency <= tunerChannel.getMaxFrequency()) + { + MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle()); + item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel))); + items.add(item); + break; + } + } + } + + return items; + } + + /** + * Channel box screen position. + */ + private record Box(double x, double width, double center) + { + } + + /** + * Channel details for the popout card. + */ + private record PopoutEntry(String title, List details, Color color) + { + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelTuningLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelTuningLayer.java new file mode 100644 index 00000000..dd15dff8 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelTuningLayer.java @@ -0,0 +1,253 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum.layer; + +import io.github.dsheirer.gui.spectrum.RenderArea; +import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; +import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; +import java.text.DecimalFormat; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +/** + * Channel tuning layer for a single channel's spectrum, centered on the channel frequency. Shades the channel + * passband and marks its edges with the theme's processing channel color, and draws the estimated carrier position + * from the decoder's frequency correction (PLL) as a line in the theme's correction color, labeled with the offset + * from the channel center. Both markers also show on the waterfall so carrier drift is easy to follow. + */ +public class ChannelTuningLayer extends SpectralDisplayLayer +{ + private static final Font FONT = Font.font(11); + private static final DecimalFormat KHZ_FORMAT = new DecimalFormat("0.###"); + private static final DecimalFormat OFFSET_FORMAT = new DecimalFormat("+0;-0"); + + private final IntegerProperty mChannelBandwidth = new SimpleIntegerProperty(0); + private final LongProperty mCarrierOffset = new SimpleLongProperty(0); + private final BooleanProperty mCarrierOffsetValid = new SimpleBooleanProperty(false); + private final Text mMeasure = new Text(); + private final double mTextHeight; + + /** + * Constructs an instance. + */ + public ChannelTuningLayer() + { + mMeasure.setFont(FONT); + mMeasure.setText("0123456789 kHz"); + mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight()); + mChannelBandwidth.addListener((ob, ov, nv) -> requestRender()); + mCarrierOffset.addListener((ob, ov, nv) -> requestRender()); + mCarrierOffsetValid.addListener((ob, ov, nv) -> requestRender()); + } + + /** + * Channel bandwidth in Hertz, or zero for no passband markers. JavaFX thread only. + */ + public IntegerProperty channelBandwidthProperty() + { + return mChannelBandwidth; + } + + /** + * Estimated carrier offset from the channel center in Hertz. JavaFX thread only. + */ + public LongProperty carrierOffsetProperty() + { + return mCarrierOffset; + } + + /** + * Indicates if the decoder has reported a carrier offset. The carrier marker only shows when true. + */ + public BooleanProperty carrierOffsetValidProperty() + { + return mCarrierOffsetValid; + } + + @Override + public String getConfigurationTitle() + { + return null; + } + + @Override + public void renderSpectrumBackground(GraphicsContext gc, RenderArea area) + { + double[] edges = getEdges(area.width()); + + if(edges != null) + { + gc.setFill(fade(getTheme().getChannelProcessing(), 0.6)); + gc.fillRect(edges[0], 0, edges[1] - edges[0], area.plotHeight()); + } + } + + @Override + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayTheme theme = getTheme(); + double[] edges = getEdges(area.width()); + + gc.setFont(FONT); + gc.setTextBaseline(VPos.TOP); + gc.setLineWidth(1.0); + + if(edges != null) + { + Color edge = opaque(theme.getChannelProcessing()); + gc.setStroke(edge); + gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.plotHeight()); + gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.plotHeight()); + + String label = formatBandwidth(mChannelBandwidth.get()); + mMeasure.setText(label); + + if(mMeasure.getLayoutBounds().getWidth() + 8 < edges[1] - edges[0]) + { + gc.setFill(edge); + gc.setTextAlign(TextAlignment.CENTER); + gc.fillText(label, (edges[0] + edges[1]) / 2.0, 2); + } + } + + Double carrier = getCarrierX(area.width()); + + if(carrier != null) + { + Color color = theme.getCorrection(); + gc.setStroke(color); + gc.strokeLine(snap(carrier), 0, snap(carrier), area.plotHeight()); + + String label = "PLL " + OFFSET_FORMAT.format(mCarrierOffset.get()) + " Hz"; + mMeasure.setText(label); + double width = mMeasure.getLayoutBounds().getWidth(); + double x = carrier + 5; + + if(x + width + 4 > area.width()) + { + x = carrier - 5 - width; + } + + double y = mTextHeight + 6; + gc.setFill(theme.getBackground(0.7)); + gc.fillRoundRect(x - 3, y - 1, width + 6, mTextHeight + 2, 4, 4); + gc.setFill(color); + gc.setTextAlign(TextAlignment.LEFT); + gc.fillText(label, x, y); + } + } + + @Override + public void renderWaterfall(GraphicsContext gc, RenderArea area) + { + SpectralDisplayTheme theme = getTheme(); + double[] edges = getEdges(area.width()); + gc.setLineWidth(1.0); + + if(edges != null) + { + gc.setStroke(fade(opaque(theme.getChannelProcessing()), 0.7)); + gc.setLineDashes(4.0, 4.0); + gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.height()); + gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.height()); + gc.setLineDashes((double[])null); + } + + Double carrier = getCarrierX(area.width()); + + if(carrier != null) + { + gc.setStroke(fade(theme.getCorrection(), 0.6)); + gc.strokeLine(snap(carrier), 0, snap(carrier), area.height()); + } + } + + /** + * Screen x positions of the lower and upper channel edges, or null when there is no channel bandwidth. + */ + private double[] getEdges(double width) + { + SpectralDisplayModel model = getModel(); + int bandwidth = mChannelBandwidth.get(); + + if(model == null || !model.hasSource() || bandwidth <= 0) + { + return null; + } + + long center = model.getCenterFrequency(); + return new double[]{model.getX(center - (bandwidth / 2.0), width), model.getX(center + (bandwidth / 2.0), + width)}; + } + + /** + * Screen x position of the estimated carrier, or null when there is no carrier estimate. + */ + private Double getCarrierX(double width) + { + SpectralDisplayModel model = getModel(); + + if(model == null || !model.hasSource() || !mCarrierOffsetValid.get()) + { + return null; + } + + return model.getX(model.getCenterFrequency() + mCarrierOffset.get(), width); + } + + private SpectralDisplayTheme getTheme() + { + return getSpectralDisplay().getTheme(); + } + + /** + * Formats a bandwidth in Hz or kHz. + */ + static String formatBandwidth(int hertz) + { + return hertz >= 1000 ? KHZ_FORMAT.format(hertz / 1000.0) + " kHz" : hertz + " Hz"; + } + + private static Color opaque(Color color) + { + return Color.color(color.getRed(), color.getGreen(), color.getBlue()); + } + + private static Color fade(Color color, double fraction) + { + return Color.color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * fraction); + } + + private static double snap(double x) + { + return Math.floor(x) + 0.5; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayer.java new file mode 100644 index 00000000..b6f80fa9 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayer.java @@ -0,0 +1,183 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum.layer; + +import io.github.dsheirer.gui.spectrum.ConfigurationRow; +import io.github.dsheirer.gui.spectrum.RenderArea; +import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; +import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import java.util.List; + +/** + * Decibel scale layer. Draws horizontal grid lines across the spectrum at even dBFS steps, with labels along the + * left edge. The step size (1, 2, 5, 10 or 20 dB) adapts to the spectrum height so the labels don't crowd. + * + * The spectrum plot runs from 0 dBFS at the top to minus the model's dynamic range at the bottom. + */ +public class DecibelLayer extends SpectralDisplayLayer +{ + private static final Font FONT = Font.font(11); + private static final double[] STEPS = {1, 2, 5, 10, 20, 50}; + private static final double MIN_LINE_SPACING = 28; + private static final double TOP_MARGIN = 22; + + private final BooleanProperty mVisible = new SimpleBooleanProperty(true); + private final Text mMeasure = new Text(); + private final double mTextHeight; + + /** + * Constructs an instance. + */ + public DecibelLayer() + { + mMeasure.setFont(FONT); + mMeasure.setText("-0123456789 dBFS"); + mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight()); + mVisible.addListener((ob, ov, nv) -> requestRender()); + } + + /** + * Shows or hides the decibel scale. + */ + public BooleanProperty visibleProperty() + { + return mVisible; + } + + @Override + public String getConfigurationTitle() + { + //A single switch - list it in the Display section instead of a section of its own + return null; + } + + @Override + public List getConfigurationRows() + { + return List.of(ConfigurationRow.toggle("dB Scale", mVisible)); + } + + /** + * Grid line spacing in dB so the lines are at least MIN_LINE_SPACING pixels apart. + */ + static double getStep(double range, double plotHeight) + { + if(plotHeight <= 0 || range <= 0) + { + return STEPS[STEPS.length - 1]; + } + + double pixelsPerDb = plotHeight / range; + + for(double step: STEPS) + { + if(step * pixelsPerDb >= MIN_LINE_SPACING) + { + return step; + } + } + + return STEPS[STEPS.length - 1]; + } + + @Override + public void renderSpectrumBackground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(!mVisible.get() || model == null || !model.hasSource()) + { + return; + } + + double range = model.getDynamicRange(); + double step = getStep(range, area.plotHeight()); + gc.setStroke(getSpectralDisplay().getTheme().getLine(0.35)); + gc.setLineWidth(1.0); + gc.setLineDashes(2.0, 4.0); + + for(double decibels = -step; decibels > -range; decibels -= step) + { + double y = Math.floor(getY(decibels, range, area.plotHeight())) + 0.5; + gc.strokeLine(0, y, area.width(), y); + } + + gc.setLineDashes((double[])null); + } + + @Override + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(!mVisible.get() || model == null || !model.hasSource()) + { + return; + } + + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + double range = model.getDynamicRange(); + double plotHeight = area.plotHeight(); + double step = getStep(range, plotHeight); + boolean unitShown = false; + + gc.setFont(FONT); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.CENTER); + + for(double decibels = -step; decibels > -range; decibels -= step) + { + double y = getY(decibels, range, plotHeight); + + //Leave room for the zoom indicator at the top and the frequency labels at the bottom + if(y < TOP_MARGIN || y > plotHeight - (mTextHeight / 2.0)) + { + continue; + } + + String label = format(decibels) + (unitShown ? "" : " dBFS"); + unitShown = true; + mMeasure.setText(label); + double width = mMeasure.getLayoutBounds().getWidth(); + gc.setFill(theme.getBackground(0.6)); + gc.fillRoundRect(2, y - (mTextHeight / 2.0), width + 6, mTextHeight, 4, 4); + gc.setFill(theme.getLine()); + gc.fillText(label, 5, y); + } + } + + private static double getY(double decibels, double range, double plotHeight) + { + return -decibels / range * plotHeight; + } + + private static String format(double decibels) + { + return decibels == Math.rint(decibels) ? String.valueOf((long)decibels) : String.valueOf(decibels); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java new file mode 100644 index 00000000..cf903ec3 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java @@ -0,0 +1,313 @@ +/* + * ***************************************************************************** + * 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.gui.spectrum.layer; + +import io.github.dsheirer.gui.spectrum.DisplayCursor; +import io.github.dsheirer.gui.spectrum.DisplaySection; +import io.github.dsheirer.gui.spectrum.RenderArea; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; +import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; +import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; +import java.text.DecimalFormat; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; + +/** + * Frequency axis layer. Draws frequency labels and tick marks along the bottom of the spectrum, faint vertical grid + * lines at each label, and a cursor line with a frequency readout that follows the mouse across the spectrum and the + * waterfall. Over the spectrum, the readout also shows the level under the mouse in dB. + */ +public class FrequencyLayer extends SpectralDisplayLayer +{ + private static final Font FONT = Font.font(11); + private static final double INSET_PADDING = 8.0; + private static final double MAJOR_TICK = 6.0; + private static final double MINOR_TICK = 3.0; + private static final DecimalFormat CURSOR_FREQUENCY_FORMAT = new DecimalFormat("0.00000"); + private static final DecimalFormat CURSOR_LEVEL_FORMAT = new DecimalFormat("0.0"); + + private final Text mMeasure = new Text(); + private final double mTextHeight; + private final DecimalFormat mLabelFormat = new DecimalFormat("0.0"); + + /** + * Constructs an instance. + */ + public FrequencyLayer() + { + mMeasure.setFont(FONT); + mMeasure.setText("0123456789."); + mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight()); + } + + @Override + public String getConfigurationTitle() + { + return "Frequency"; + } + + @Override + public double getSpectrumBottomInset() + { + return mTextHeight + INSET_PADDING; + } + + @Override + public void renderSpectrumBackground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(model == null || !model.hasSource()) + { + return; + } + + Scale scale = getScale(model, area.width()); + gc.setStroke(getTheme().getLine(0.35)); + gc.setLineWidth(1.0); + gc.setLineDashes(2.0, 4.0); + + for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep()); + frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep()) + { + double x = snap(model.getX(frequency, area.width())); + gc.strokeLine(x, 0, x, area.plotHeight()); + } + + gc.setLineDashes((double[])null); + } + + @Override + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + if(model != null && model.hasSource()) + { + renderAxis(gc, area, model); + } + + renderCursor(gc, area, DisplaySection.SPECTRUM); + } + + @Override + public void renderWaterfall(GraphicsContext gc, RenderArea area) + { + renderCursor(gc, area, DisplaySection.WATERFALL); + } + + /** + * Draws the tick marks and frequency labels in the inset below the spectrum plot. + */ + private void renderAxis(GraphicsContext gc, RenderArea area, SpectralDisplayModel model) + { + double width = area.width(); + double base = Math.floor(area.plotHeight()) + 0.5; + Scale scale = getScale(model, width); + Color line = getTheme().getLine(); + + gc.setStroke(line); + gc.setLineWidth(1.0); + + for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.minorStep()); + frequency <= model.getDisplayMaxFrequency(); frequency += scale.minorStep()) + { + double x = snap(model.getX(frequency, width)); + double length = (frequency % scale.labelStep() == 0) ? MAJOR_TICK : MINOR_TICK; + gc.strokeLine(x, base, x, base + length); + } + + gc.setFill(line); + gc.setFont(FONT); + gc.setTextAlign(TextAlignment.CENTER); + gc.setTextBaseline(VPos.BOTTOM); + setPrecision(scale.decimals()); + + for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep()); + frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep()) + { + double x = model.getX(frequency, width); + String label = mLabelFormat.format(frequency / 1E6); + double half = measure(label) / 2.0; + + //Skip labels that would be cut off at the edges + if(x - half >= 0 && x + half <= width) + { + gc.fillText(label, x, area.height() - 1); + } + } + } + + /** + * Draws the cursor line in both sections and the readout in the section under the mouse. + */ + private void renderCursor(GraphicsContext gc, RenderArea area, DisplaySection section) + { + DisplayCursor cursor = getSpectralDisplay().getDisplayCursor(); + SpectralDisplayModel model = getModel(); + + if(cursor == null || model == null || !model.hasSource()) + { + return; + } + + Color color = getTheme().getCursor(); + double x = snap(cursor.x()); + gc.setStroke(color); + gc.setLineWidth(1.0); + gc.strokeLine(x, 0, x, area.plotHeight()); + + if(cursor.section() != section) + { + return; + } + + String frequency = CURSOR_FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz"; + String level = null; + + if(section == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0) + { + double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange(); + level = CURSOR_LEVEL_FORMAT.format(decibels) + " dBFS"; + } + + double textWidth = measure(frequency); + + if(level != null) + { + textWidth = Math.max(textWidth, measure(level)); + } + + double lines = level != null ? 2 : 1; + double boxWidth = textWidth + 8; + double boxHeight = (mTextHeight * lines) + 4; + double boxX = cursor.x() + 8; + + if(boxX + boxWidth > area.width()) + { + boxX = cursor.x() - 8 - boxWidth; + } + + double boxY = Math.max(0, Math.min(area.plotHeight() - boxHeight, cursor.y() - boxHeight - 4)); + gc.setFill(getTheme().getBackground(0.7)); + gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6); + gc.setFill(color); + gc.setFont(FONT); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.TOP); + gc.fillText(frequency, boxX + 4, boxY + 2); + + if(level != null) + { + gc.fillText(level, boxX + 4, boxY + 2 + mTextHeight); + } + } + + private SpectralDisplayTheme getTheme() + { + return getSpectralDisplay().getTheme(); + } + + /** + * Chooses the label spacing so the labels fill no more than half of the display width. + */ + private Scale getScale(SpectralDisplayModel model, double width) + { + double bandwidth = model.getDisplayBandwidth(); + int decimals = 3; + long step = 1; + + //Two passes: the label width depends on the number of decimals, which depends on the label spacing + for(int pass = 0; pass < 2; pass++) + { + setPrecision(decimals); + double labelWidth = measure(mLabelFormat.format(model.getDisplayMaxFrequency() / 1E6)); + double maxLabels = Math.max(1.0, width / (labelWidth * 2.0)); + step = niceStep(bandwidth / maxLabels); + decimals = Math.max(1, Math.min(6, (int)Math.ceil(6.0 - Math.log10(step) - 1E-9))); + } + + long mantissa = step / (long)Math.pow(10, Math.floor(Math.log10(step))); + long minor = Math.max(1, step / (mantissa == 2 ? 4 : 5)); + return new Scale(step, minor, decimals); + } + + /** + * Smallest 1, 2 or 5 times a power of ten that is at least the value, with a minimum of 1 Hertz. + */ + static long niceStep(double value) + { + if(value <= 1) + { + return 1; + } + + double power = Math.pow(10, Math.floor(Math.log10(value))); + + for(int mantissa: new int[]{1, 2, 5, 10}) + { + if(mantissa * power >= value) + { + return Math.round(mantissa * power); + } + } + + return Math.round(10 * power); + } + + /** + * First multiple of the step at or above the frequency. + */ + private static long firstMultiple(double frequency, long step) + { + return (long)Math.ceil(frequency / step) * step; + } + + /** + * Centers a line on a pixel so one pixel wide lines are sharp. + */ + private static double snap(double x) + { + return Math.floor(x) + 0.5; + } + + private void setPrecision(int decimals) + { + mLabelFormat.setMinimumFractionDigits(decimals); + mLabelFormat.setMaximumFractionDigits(decimals); + } + + private double measure(String text) + { + mMeasure.setText(text); + return mMeasure.getLayoutBounds().getWidth(); + } + + /** + * Label and tick spacing in Hertz and the number of label decimal places (MHz). + */ + private record Scale(long labelStep, long minorStep, int decimals) + { + } +} diff --git a/src/main/java/io/github/dsheirer/gui/symbol/SymbolView.java b/src/main/java/io/github/dsheirer/gui/symbol/SymbolView.java index d2927a1f..852b8849 100644 --- a/src/main/java/io/github/dsheirer/gui/symbol/SymbolView.java +++ b/src/main/java/io/github/dsheirer/gui/symbol/SymbolView.java @@ -19,18 +19,23 @@ package io.github.dsheirer.gui.symbol; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; import io.github.dsheirer.module.decode.FeedbackDecoder; import io.github.dsheirer.sample.Listener; +import java.net.URL; import java.util.Arrays; import javafx.application.Platform; +import javafx.beans.value.ObservableValue; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.ScatterChart; import javafx.scene.chart.XYChart; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; /** - * Chart display for received QPSK symbols + * Chart display for received QPSK symbols. Colors follow a spectral display theme: the theme background, the + * spectrum trace color for the symbols, and the theme line color for the grid, axes and legend. */ public class SymbolView extends ChannelView implements Listener { @@ -57,6 +62,14 @@ public class SymbolView extends ChannelView implements Listener mSymbolSeries.getData().add(new XYChart.Data<>(x, 0)); } + getStyleClass().add("symbol-view"); + URL stylesheet = SymbolView.class.getResource("/spectral_display.css"); + + if(stylesheet != null) + { + getStylesheets().add(stylesheet.toExternalForm()); + } + mSymbolChart.getData().add(mSymbolSeries); mSymbolChart.setMaxHeight(Double.MAX_VALUE); mSymbolChart.setMaxWidth(Double.MAX_VALUE); @@ -65,6 +78,46 @@ public class SymbolView extends ChannelView implements Listener getChildren().add(mSymbolChart); } + /** + * Applies the theme colors now and whenever the theme changes. JavaFX thread only. + * @param theme to follow, for example the theme shared by the spectral displays + */ + public void followTheme(ObservableValue theme) + { + theme.addListener((ob, ov, nv) -> setTheme(nv)); + setTheme(theme.getValue()); + } + + /** + * Applies the theme colors to the chart. JavaFX thread only. + */ + public void setTheme(SpectralDisplayTheme theme) + { + if(theme == null) + { + theme = SpectralDisplayTheme.getDefault(); + } + + Color line = theme.getLine(); + Color text = Color.color(line.getRed(), line.getGreen(), line.getBlue()); + Color trace = theme.getTrace(); + Color symbol = Color.color(trace.getRed(), trace.getGreen(), trace.getBlue(), + Math.max(0.6, trace.getOpacity())); + + //Looked-up colors used by the symbol-view rules in spectral_display.css + setStyle("symbol-background: " + toWeb(theme.getBackground()) + ";" + + "symbol-color: " + toWeb(symbol) + ";" + + "symbol-grid: " + toWeb(theme.getLine(0.35)) + ";" + + "symbol-text: " + toWeb(text) + ";"); + } + + private static String toWeb(Color color) + { + return String.format("#%02x%02x%02x%02x", Math.round(color.getRed() * 255), + Math.round(color.getGreen() * 255), Math.round(color.getBlue() * 255), + Math.round(color.getOpacity() * 255)); + } + /** * Updates the chart axis label with the currently displayed protocol * @param protocol that is being processed/displayed. diff --git a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerEditor.java b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerEditor.java index 4e816c9a..6751baef 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerEditor.java +++ b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerEditor.java @@ -34,7 +34,7 @@ import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; import io.github.dsheirer.source.tuner.manager.IDiscoveredTunerStatusListener; import io.github.dsheirer.source.tuner.manager.TunerManager; import io.github.dsheirer.source.tuner.manager.TunerStatus; -import io.github.dsheirer.spectrum.SpectralDisplayPanel; +import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay; import io.github.dsheirer.util.SwingUtils; import io.github.dsheirer.util.ThreadPool; import java.awt.EventQueue; @@ -592,7 +592,7 @@ public abstract class TunerEditor if(tuner != null) { - SystemProperties.getInstance().set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true); + SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true); ThreadPool.CACHED.submit(() -> mTunerManager.getDiscoveredTunerModel().broadcast(new TunerEvent(tuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY))); diff --git a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerSpectralDisplayManager.java b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerSpectralDisplayManager.java index a85db422..a0d270fd 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/ui/TunerSpectralDisplayManager.java +++ b/src/main/java/io/github/dsheirer/source/tuner/ui/TunerSpectralDisplayManager.java @@ -21,12 +21,12 @@ package io.github.dsheirer.source.tuner.ui; import io.github.dsheirer.playlist.PlaylistManager; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.sample.Listener; -import io.github.dsheirer.settings.SettingsManager; import io.github.dsheirer.source.tuner.Tuner; import io.github.dsheirer.source.tuner.TunerEvent; import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; -import io.github.dsheirer.spectrum.SpectralDisplayPanel; -import io.github.dsheirer.spectrum.SpectrumFrame; +import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel; +import io.github.dsheirer.gui.spectrum.SpectralDisplayWindow; +import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay; import io.github.dsheirer.util.SwingUtils; import io.github.dsheirer.util.ThreadPool; import java.util.List; @@ -43,22 +43,19 @@ public class TunerSpectralDisplayManager implements Listener private SpectralDisplayPanel mSpectralDisplayPanel; private PlaylistManager mPlaylistManager; - private SettingsManager mSettingsManager; private DiscoveredTunerModel mDiscoveredTunerModel; /** * Constructs an instance - * @param panel to manage + * @param panel to manage, or null when running headless * @param playlistManager for channel updates - * @param settingsManager for settings * @param discoveredTunerModel to access tuners */ public TunerSpectralDisplayManager(SpectralDisplayPanel panel, PlaylistManager playlistManager, - SettingsManager settingsManager, DiscoveredTunerModel discoveredTunerModel) + DiscoveredTunerModel discoveredTunerModel) { mSpectralDisplayPanel = panel; mPlaylistManager = playlistManager; - mSettingsManager = settingsManager; mDiscoveredTunerModel = discoveredTunerModel; } @@ -68,7 +65,8 @@ public class TunerSpectralDisplayManager implements Listener public Tuner showFirstTuner() { //Ensure spectral display is enabled before selecting first tuner - if(SystemProperties.getInstance().get(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true)) + if(mSpectralDisplayPanel != null && + SystemProperties.getInstance().get(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true)) { List availableTuners = mDiscoveredTunerModel.getAvailableTuners(); @@ -88,20 +86,24 @@ public class TunerSpectralDisplayManager implements Listener @Override public void receive(TunerEvent event) { + if(mSpectralDisplayPanel == null) + { + return; + } + switch(event.getEvent()) { case REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY: SwingUtils.run(() -> mSpectralDisplayPanel.clearTuner()); break; case REQUEST_MAIN_SPECTRAL_DISPLAY: - if(SystemProperties.getInstance().get(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true)) + if(SystemProperties.getInstance().get(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true)) { SwingUtils.run(() -> mSpectralDisplayPanel.showTuner(event.getTuner())); } break; case REQUEST_NEW_SPECTRAL_DISPLAY: - final SpectrumFrame frame = new SpectrumFrame(mPlaylistManager, mSettingsManager, mDiscoveredTunerModel, event.getTuner()); - SwingUtils.run(() -> frame.setVisible(true)); + SpectralDisplayWindow.show(mPlaylistManager, mDiscoveredTunerModel, event.getTuner()); break; case NOTIFICATION_ERROR_STATE: case NOTIFICATION_SHUTTING_DOWN: diff --git a/src/main/java/io/github/dsheirer/spectrum/ComplexDftProcessor.java b/src/main/java/io/github/dsheirer/spectrum/ComplexDftProcessor.java index 7fc4c488..bcec0e9d 100644 --- a/src/main/java/io/github/dsheirer/spectrum/ComplexDftProcessor.java +++ b/src/main/java/io/github/dsheirer/spectrum/ComplexDftProcessor.java @@ -46,10 +46,12 @@ public class ComplexDftProcessor implements Listener private static final String FRAME_RATE_PROPERTY = "spectral.display.frame.rate"; //The Cosine and Hann windows seem to offer the best spectral display with minimal bin leakage/smearing - private WindowType mWindowType = WindowType.BLACKMAN_HARRIS_7; + private volatile WindowType mWindowType = WindowType.BLACKMAN_HARRIS_7; + private volatile boolean mWindowChanged = true; + private volatile boolean mUnityGainWindow = false; private float[] mWindow; private DFTSize mDFTSize = DFTSize.FFT04096; - private DFTSize mNewDFTSize = DFTSize.FFT04096; + private volatile DFTSize mNewDFTSize = DFTSize.FFT04096; private FloatFFT_1D mFFT = new FloatFFT_1D(mDFTSize.getSize()); private int mFrameRate; private AtomicBoolean mRunning = new AtomicBoolean(); @@ -62,13 +64,14 @@ public class ComplexDftProcessor implements Listener public ComplexDftProcessor() { mFrameRate = SystemProperties.getInstance().get(FRAME_RATE_PROPERTY, 20); - setWindowType(mWindowType); + mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow); start(); } public void dispose() { stop(); + mExecutorService.shutdown(); mListeners.clear(); mWindow = null; @@ -79,15 +82,87 @@ public class ComplexDftProcessor implements Listener return mWindowType; } + /** + * Queues a window type change. The processor applies the change before the next calculation. + */ public void setWindowType(WindowType windowType) { - mWindowType = windowType; - updateWindow(); + if(windowType != null) + { + mWindowType = windowType; + mWindowChanged = true; + } } - private void updateWindow() + /** + * Scales the window to unity coherent gain, so that a full scale complex tone centered in a bin has a DFT + * magnitude equal to the DFT size, regardless of the window type. Use this with a full scale (dBFS) converter. + * Defaults to false, which leaves the window coefficients unscaled. + */ + public void setUnityGainWindow(boolean unityGain) { - mWindow = WindowFactory.getWindow(mWindowType, mDFTSize.getSize() * 2); + mUnityGainWindow = unityGain; + mWindowChanged = true; + } + + /** + * Creates the window coefficients for interleaved complex samples, with the same coefficient applied to the I + * and Q values of each sample. + * @param type of window. Kaiser windows need extra parameters, so Kaiser uses a Blackman-Harris 7 window. + * @param size of the DFT in complex samples + * @param unityGain to scale the window so the coefficients sum to the DFT size (unity coherent gain) + * @return interleaved window coefficients, twice the DFT size + */ + public static float[] createWindow(WindowType type, int size, boolean unityGain) + { + if(type == null || type == WindowType.KAISER) + { + type = WindowType.BLACKMAN_HARRIS_7; + } + + float[] window = WindowFactory.getWindow(type, size); + double sum = 0.0; + + for(float coefficient: window) + { + sum += coefficient; + } + + float scale = (unityGain && sum > 0) ? (float)(size / sum) : 1.0f; + float[] interleaved = new float[size * 2]; + + for(int x = 0; x < size; x++) + { + interleaved[2 * x] = window[x] * scale; + interleaved[2 * x + 1] = window[x] * scale; + } + + return interleaved; + } + + /** + * Equivalent noise bandwidth of the window type, in DFT bins. This is how much wider the window's noise + * bandwidth is than one bin: 1.0 for no window and about 2 to 4 for the low leakage windows. + */ + public static double getNoiseBandwidth(WindowType type) + { + if(type == null || type == WindowType.KAISER) + { + type = WindowType.BLACKMAN_HARRIS_7; + } + + int size = 4096; + float[] window = WindowFactory.getWindow(type, size); + double sum = 0.0; + double sumSquares = 0.0; + + for(float coefficient: window) + { + sum += coefficient; + sumSquares += coefficient * coefficient; + } + + return sum > 0 ? size * sumSquares / (sum * sum) : 1.0; } /** @@ -110,6 +185,15 @@ public class ComplexDftProcessor implements Listener } public void setFrameRate(int framesPerSecond) + { + setFrameRateNoSave(framesPerSecond); + SystemProperties.getInstance().set(FRAME_RATE_PROPERTY, mFrameRate); + } + + /** + * Sets the frame rate without saving it as the default frame rate setting. + */ + public void setFrameRateNoSave(int framesPerSecond) { if(framesPerSecond < 1 || framesPerSecond > 1000) { @@ -118,7 +202,6 @@ public class ComplexDftProcessor implements Listener } mFrameRate = framesPerSecond; - SystemProperties.getInstance().set(FRAME_RATE_PROPERTY, mFrameRate); restart(); } @@ -176,6 +259,11 @@ public class ComplexDftProcessor implements Listener { //If this throws an IO exception, the buffer queue is (temporarily) empty and we return from the method float[] samples = mDftBufferManager.get(mDFTSize.getSize()); + if(mWindow == null || mWindow.length != samples.length) + { + mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow); + } + WindowFactory.apply(mWindow, samples); mFFT.complexForward(samples); mPreviousSamples = samples; @@ -237,7 +325,7 @@ public class ComplexDftProcessor implements Listener } /** - * Checks for a queued FFT width change request and applies it. This + * Checks for a queued FFT width or window change request and applies it. This * method will only be accessed by the scheduled executor that gains * access to run a calculate method, thus providing thread safety. */ @@ -246,9 +334,15 @@ public class ComplexDftProcessor implements Listener if(mNewDFTSize.getSize() != mDFTSize.getSize()) { mDFTSize = mNewDFTSize; - updateWindow(); + mWindowChanged = true; mFFT = new FloatFFT_1D(mDFTSize.getSize()); } + + if(mWindowChanged) + { + mWindowChanged = false; + mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow); + } } public void clearBuffer() diff --git a/src/main/java/io/github/dsheirer/spectrum/DisableSpectrumWaterfallMenuItem.java b/src/main/java/io/github/dsheirer/spectrum/DisableSpectrumWaterfallMenuItem.java index 3d6a302e..69681ad4 100644 --- a/src/main/java/io/github/dsheirer/spectrum/DisableSpectrumWaterfallMenuItem.java +++ b/src/main/java/io/github/dsheirer/spectrum/DisableSpectrumWaterfallMenuItem.java @@ -18,7 +18,10 @@ */ package io.github.dsheirer.spectrum; +import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel; +import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay; import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.util.ThreadPool; import jiconfont.icons.font_awesome.FontAwesome; import io.github.dsheirer.gui.theme.ThemedFontIcon; @@ -44,8 +47,8 @@ public class DisableSpectrumWaterfallMenuItem extends JMenuItem addActionListener(e -> { SystemProperties properties = SystemProperties.getInstance(); - properties.set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, false); - mSpectralDisplayPanel.clearTuner(); + properties.set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false); + ThreadPool.CACHED.submit(() -> mSpectralDisplayPanel.clearTuner()); }); } } diff --git a/src/main/java/io/github/dsheirer/spectrum/FrequencyOverlayPanel.java b/src/main/java/io/github/dsheirer/spectrum/FrequencyOverlayPanel.java deleted file mode 100644 index 72e8076d..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/FrequencyOverlayPanel.java +++ /dev/null @@ -1,608 +0,0 @@ -/* - * ***************************************************************************** - * 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.spectrum; - -import io.github.dsheirer.settings.ColorSetting; -import io.github.dsheirer.settings.ColorSetting.ColorSettingName; -import io.github.dsheirer.settings.Setting; -import io.github.dsheirer.settings.SettingChangeListener; -import io.github.dsheirer.settings.SettingsManager; -import io.github.dsheirer.source.ISourceEventProcessor; -import io.github.dsheirer.source.SourceEvent; -import java.awt.Color; -import java.awt.FontMetrics; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Point; -import java.awt.RenderingHints; -import java.awt.event.ComponentEvent; -import java.awt.event.ComponentListener; -import java.awt.geom.Line2D; -import java.awt.geom.Rectangle2D; -import java.text.DecimalFormat; -import org.apache.commons.math3.util.FastMath; - -import javax.swing.JPanel; - -/** - * Frequency overlay panel. - */ -public class FrequencyOverlayPanel extends JPanel implements ISourceEventProcessor, SettingChangeListener -{ - private static final long serialVersionUID = 1L; - private final DecimalFormat PPM_FORMATTER = new DecimalFormat( "#.0" ); - private final static RenderingHints RENDERING_HINTS = new RenderingHints(RenderingHints.KEY_ANTIALIASING, - RenderingHints.VALUE_ANTIALIAS_ON); - static - { - RENDERING_HINTS.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - } - - private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("000.00000"); - private long mFrequency = 0; - private int mBandwidth = 0; - private int mChannelBandwidth = 0; - - private long mEstimatedCarrierOffsetFrequency = 0; - private Point mCursorLocation = new Point(0, 0); - private boolean mCursorVisible = false; - - private DFTSize mDFTSize = DFTSize.FFT04096; - - /** - * Colors used by this component - */ - private Color mColorSpectrumBackground; - private Color mColorSpectrumCursor; - private Color mColorSpectrumLine; - - //Defines the offset at the bottom of the spectral display to account for - //the frequency labels - private double mSpectrumInset = 20.0d; - private LabelSizeManager mLabelSizeMonitor = new LabelSizeManager(); - - private SettingsManager mSettingsManager; - - /** - * Translucent overlay panel for displaying channel configurations, - * processing channels, selected channels, frequency labels and lines, and - * a cursor with a frequency readout. - */ - public FrequencyOverlayPanel(SettingsManager settingsManager) - { - mSettingsManager = settingsManager; - - if(mSettingsManager != null) - { - mSettingsManager.addListener(this); - } - - addComponentListener(mLabelSizeMonitor); - - //Set the background transparent, so the spectrum display can be seen - setOpaque(false); - - //Fetch color settings from settings manager - setColors(); - } - - /** - * Sets the estimated carrier offset of the channel's signal - * @param estimatedCarrierOffsetFrequency current setting. Set to 0 to reset. - */ - public void setEstimatedCarrierOffsetFrequency(long estimatedCarrierOffsetFrequency) - { - mEstimatedCarrierOffsetFrequency = estimatedCarrierOffsetFrequency; - } - - /** - * Sets the bandwidth of the channel. - * @param channelBandwidth in Hertz - */ - public void setChannelBandwidth(int channelBandwidth) - { - mChannelBandwidth = channelBandwidth; - } - - public void dispose() - { - if(mSettingsManager != null) - { - mSettingsManager.removeListener(this); - } - - mSettingsManager = null; - } - - /** - * Sets/changes the DFT bin size - */ - public void setDFTSize(DFTSize size) - { - mDFTSize = size; - } - - public void setCursorLocation(Point point) - { - mCursorLocation = point; - - repaint(); - } - - public void setCursorVisible(boolean visible) - { - mCursorVisible = visible; - repaint(); - } - - /** - * Fetches the color settings from the settings manager - */ - private void setColors() - { - mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR); - mColorSpectrumLine = getColor(ColorSettingName.SPECTRUM_LINE); - mColorSpectrumBackground = getColor(ColorSettingName.SPECTRUM_BACKGROUND); - } - - /** - * Fetches a named color setting from the settings manager. If the setting - * doesn't exist, creates the setting using the defaultColor - */ - private Color getColor(ColorSettingName name) - { - ColorSetting setting = mSettingsManager.getColorSetting(name); - return setting.getColor(); - } - - /** - * Monitors for setting changes. Colors can be changed by external actions - * and will automatically update in this class - */ - @Override - public void settingChanged(Setting setting) - { - if(setting instanceof ColorSetting) - { - ColorSetting colorSetting = (ColorSetting)setting; - - switch(colorSetting.getColorSettingName()) - { - case SPECTRUM_BACKGROUND: - mColorSpectrumBackground = colorSetting.getColor(); - break; - case SPECTRUM_CURSOR: - mColorSpectrumCursor = colorSetting.getColor(); - break; - case SPECTRUM_LINE: - mColorSpectrumLine = colorSetting.getColor(); - break; - default: - break; - } - } - } - - /** - * Renders the channel configs, lines, labels, and cursor - */ - @Override - public void paintComponent(Graphics g) - { - super.paintComponent(g); - - Graphics2D graphics = (Graphics2D)g; - graphics.setBackground(mColorSpectrumBackground); - graphics.setRenderingHints(RENDERING_HINTS); - drawFrequencies(graphics); - drawCursor(graphics); - drawEstimatedCarrierOffset(graphics); - drawChannelBandwidth(graphics); - } - - /** - * Draws a cursor on the panel, whenever the mouse is hovering over the - * panel - */ - private void drawCursor(Graphics2D graphics) - { - if(mCursorVisible) - { - drawFrequencyLine(graphics, mCursorLocation.x, mColorSpectrumCursor); - String frequency = CURSOR_FORMAT.format(getFrequencyFromAxis(mCursorLocation.getX()) / 1E6D); - FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont()); - Rectangle2D rect = fontMetrics.getStringBounds(frequency, graphics); - - if(mCursorLocation.y > rect.getHeight()) - { - graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y); - } - } - } - - /** - * Draws the frequency lines and labels every 10kHz - */ - private void drawFrequencies(Graphics2D graphics) - { - long minFrequency = getMinDisplayFrequency(); - long maxFrequency = getMaxDisplayFrequency(); - - //Frequency increments for label and tick spacing - int label = mLabelSizeMonitor.getLabelIncrement(graphics); - int major = mLabelSizeMonitor.getMajorTickIncrement(graphics); - int minor = mLabelSizeMonitor.getMinorTickIncrement(graphics); - - //Avoid divide by zero error - if(minor == 0) - { - minor = 1; - } - if(label == 0) - { - label = 1; - } - - //Adjust the start frequency to a multiple of the minor tick spacing - long frequency = minFrequency - (minFrequency % minor); - - while(frequency < maxFrequency) - { - if(frequency % label == 0) - { - drawFrequencyLineAndLabel(graphics, frequency); - } - else if(frequency % major == 0) - { - drawTickLine(graphics, frequency, true); - } - else - { - drawTickLine(graphics, frequency, false); - } - - frequency += minor; - } - } - - /** - * Draws a vertical line and a corresponding frequency label at the bottom - */ - private void drawFrequencyLineAndLabel(Graphics2D graphics, long frequency) - { - double xAxis = getAxisFromFrequency(frequency); - drawFrequencyLine(graphics, xAxis, mColorSpectrumLine); - drawTickLine(graphics, frequency, false); - graphics.setColor(mColorSpectrumLine); - drawFrequencyLabel(graphics, xAxis, frequency); - } - - /** - * Draws a vertical line at the xaxis - */ - private void drawTickLine(Graphics2D graphics, long frequency, boolean major) - { - graphics.setColor(mColorSpectrumLine); - double xAxis = getAxisFromFrequency(frequency); - double start = getSize().getHeight() - mSpectrumInset; - double end = start + (major ? 9.0d : 3.0d); - graphics.draw(new Line2D.Double(xAxis, start, xAxis, end)); - } - - - /** - * Draws a vertical line at the xaxis - */ - private void drawFrequencyLine(Graphics2D graphics, double xaxis, Color color) - { - graphics.setColor(color); - graphics.draw(new Line2D.Double(xaxis, 0.0d, xaxis, getSize().getHeight() - mSpectrumInset)); - } - - /** - * Draws a vertical line at the xaxis - */ - private void drawChannelCenterLine(Graphics2D graphics, double xaxis) - { - double height = getSize().getHeight() - mSpectrumInset; - graphics.setColor(Color.LIGHT_GRAY); - graphics.draw(new Line2D.Double(xaxis, height * 0.65d, xaxis, height - 1.0d)); - } - - /** - * Draws the estimated carrier offset measured by the CarrierOffsetProcessor - */ - private void drawEstimatedCarrierOffset(Graphics2D graphics) - { - long frequency = mFrequency + mEstimatedCarrierOffsetFrequency; - double xAxis = getAxisFromFrequency(frequency); - - double height = getSize().getHeight() - mSpectrumInset; - double verticalAxisTop = height * 0.02d; - double verticalAxisBottom = height * 0.98d; - - graphics.setColor(Color.YELLOW); - - //Vertical band edge lines - graphics.draw(new Line2D.Double(xAxis, verticalAxisTop, xAxis, verticalAxisBottom)); - } - - /** - * Draws the channel bandwidth indicators - */ - private void drawChannelBandwidth(Graphics2D graphics) - { - if(mChannelBandwidth == 0) - { - return; - } - - long minFrequency = mFrequency - (mChannelBandwidth / 2); - long maxFrequency = mFrequency + (mChannelBandwidth / 2); - - double height = getSize().getHeight() - mSpectrumInset; - double verticalAxisTop = height * 0.8d; - double verticalAxisBottom = height * 0.98d; - - double minXAxis = getAxisFromFrequency(minFrequency); - double maxXAxis = getAxisFromFrequency(maxFrequency); - - graphics.setColor(Color.GREEN); - - graphics.draw(new Line2D.Double(minXAxis, verticalAxisTop, minXAxis, verticalAxisBottom)); - graphics.draw(new Line2D.Double(maxXAxis, verticalAxisTop, maxXAxis, verticalAxisBottom)); - } - - /** - * Returns the x-axis value corresponding to the frequency - */ - private double getAxisFromFrequency(long frequency) - { - double screenWidth = (double)getSize().getWidth(); - double pixelsPerBin = screenWidth / (double)mDFTSize.getSize(); - double pixelOffsetToMinDisplayFrequency = pixelsPerBin * 2.0d; - - //Calculate frequency offset from the min frequency - double frequencyOffset = (double)(frequency - getMinDisplayFrequency()); - - //Determine ratio of frequency offset to overall bandwidth - double ratio = frequencyOffset / (double)getDisplayBandwidth(); - - //Apply the ratio to the screen width minus 1 bin width - double screenOffset = screenWidth * ratio; - return pixelOffsetToMinDisplayFrequency + screenOffset; - } - - /** - * Returns the frequency corresponding to the x-axis value using the current - * zoom level. - */ - public long getFrequencyFromAxis(double xAxis) - { - double width = getSize().getWidth(); - double offset = xAxis / width; - long frequency = getMinDisplayFrequency() + FastMath.round((double)getDisplayBandwidth() * offset); - if(frequency > (getMaxFrequency())) - { - frequency = getMaxFrequency(); - } - return frequency; - } - - /** - * Draws a frequency label at the x-axis position, at the bottom of the panel - */ - private void drawFrequencyLabel(Graphics2D graphics, double xaxis, long frequency) - { - String label = mLabelSizeMonitor.format(frequency); - FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont()); - Rectangle2D rect = fontMetrics.getStringBounds(label, graphics); - float xOffset = (float)rect.getWidth() / 2; - graphics.drawString(label, (float)(xaxis - xOffset), (float)(getSize().getHeight() - 2.0f)); - } - - /** - * Frequency change event handler - */ - @Override - public void process(SourceEvent event) - { - switch(event.getEvent()) - { - case NOTIFICATION_SAMPLE_RATE_CHANGE: - mBandwidth = event.getValue().intValue(); - mLabelSizeMonitor.update(); - break; - case NOTIFICATION_FREQUENCY_CHANGE: - mFrequency = event.getValue().longValue(); - mLabelSizeMonitor.update(); - break; - default: - break; - } - } - - public int getBandwidth() - { - return mBandwidth; - } - - /** - * Currently displayed minimum frequency - */ - public long getMinFrequency() - { - return mFrequency - (mBandwidth / 2); - } - - /** - * Currently displayed maximum frequency - */ - public long getMaxFrequency() - { - return mFrequency + (mBandwidth / 2); - } - - public boolean containsFrequency(long frequency) - { - return FastMath.abs(mFrequency - frequency) <= (mBandwidth / 2); - } - - private long getMinDisplayFrequency() - { - double bandwidthPerBin = (double)mBandwidth / (double)(mDFTSize.getSize()); - - return getMinFrequency(); -// return getMinFrequency() + (int)((mDFTZoomWindowOffset) * bandwidthPerBin); - } - - private long getMaxDisplayFrequency() - { - return getMinDisplayFrequency() + getDisplayBandwidth(); - } - - private int getDisplayBandwidth() - { - return mBandwidth; - } - - @Override - public void settingDeleted(Setting setting) { /* not implemented */ } - - /** - * Calculates correct spacing and format for frequency labels and major/minor - * tick lines based on current frequency, bandwidth, zoom and screen size. - */ - public class LabelSizeManager implements ComponentListener - { - private static final double LABEL_FILL_THRESHOLD = 0.5d; - - private DecimalFormat mFrequencyFormat = new DecimalFormat("0.0"); - - private boolean mUpdateRequired = true; - private int mLabelIncrement = 1; - private int mMajorTickIncrement = 1; - private int mMinorTickIncrement = 1; - - public String format(long frequency) - { - return mFrequencyFormat.format((double)frequency / 1E6D); - } - - private void setPrecision(int precision) - { - if(precision < 1) - { - precision = 1; - } - - if(precision > 5) - { - precision = 5; - } - - mFrequencyFormat.setMinimumFractionDigits(precision); - mFrequencyFormat.setMaximumFractionDigits(precision); - } - - private void update(Graphics2D graphics) - { - if(mUpdateRequired) - { - //Set maximum precision as a starting point - setPrecision(5); - - FontMetrics fontMetrics = - graphics.getFontMetrics(FrequencyOverlayPanel.this.getFont()); - - int maxLabelWidth = fontMetrics.stringWidth(format(getMaxDisplayFrequency())); - - double maxLabels = ((double) FrequencyOverlayPanel.this.getWidth() * LABEL_FILL_THRESHOLD) / (double)maxLabelWidth; - - //Calculate the next smallest base 10 value for the major increment - int power = (int)FastMath.log10((double)getDisplayBandwidth() / maxLabels); - - //Set the number of decimal places to display in frequency labels - int precision = 5 - power; - - int start = (int)FastMath.pow(10.0, power + 1); - - int minimum = (int)FastMath.pow(10.0, power); - - int labelIncrement = start; - - while(((double)getDisplayBandwidth() / (double)labelIncrement) < maxLabels && labelIncrement >= minimum) - { - labelIncrement /= 2; - precision++; - } - - if(labelIncrement == minimum) - { - precision = 5 - power; - } - - setPrecision(precision); - - mLabelIncrement = labelIncrement; - mMajorTickIncrement = labelIncrement / 2; - mMinorTickIncrement = labelIncrement / 10; - - mUpdateRequired = false; - } - } - - /** - * Forces the display to update the label and frequency display - * calculations - */ - public void update() - { - mUpdateRequired = true; - } - - public int getMajorTickIncrement(Graphics2D graphics) - { - //Check to see if a calculation update is scheduled - update(graphics); - - return mMajorTickIncrement; - } - - public int getMinorTickIncrement(Graphics2D graphics) - { - return mMinorTickIncrement; - } - - public int getLabelIncrement(Graphics2D graphics) - { - return mLabelIncrement; - } - - @Override - public void componentResized(ComponentEvent arg0) - { - update(); - } - - public void componentHidden(ComponentEvent arg0) {} - public void componentMoved(ComponentEvent arg0) {} - public void componentShown(ComponentEvent arg0) {} - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/OverlayPanel.java b/src/main/java/io/github/dsheirer/spectrum/OverlayPanel.java deleted file mode 100644 index 4a674900..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/OverlayPanel.java +++ /dev/null @@ -1,948 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2024 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.spectrum; - -import io.github.dsheirer.controller.channel.Channel; -import io.github.dsheirer.controller.channel.Channel.ChannelType; -import io.github.dsheirer.controller.channel.ChannelEvent; -import io.github.dsheirer.controller.channel.ChannelModel; -import io.github.dsheirer.controller.channel.ChannelProcessingManager; -import io.github.dsheirer.sample.Listener; -import io.github.dsheirer.settings.ColorSetting; -import io.github.dsheirer.settings.ColorSetting.ColorSettingName; -import io.github.dsheirer.settings.Setting; -import io.github.dsheirer.settings.SettingChangeListener; -import io.github.dsheirer.settings.SettingsManager; -import io.github.dsheirer.source.ISourceEventProcessor; -import io.github.dsheirer.source.SourceEvent; -import io.github.dsheirer.source.tuner.channel.TunerChannel; -import java.awt.BasicStroke; -import java.awt.Color; -import java.awt.Font; -import java.awt.FontMetrics; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Point; -import java.awt.RenderingHints; -import java.awt.Stroke; -import java.awt.event.ComponentEvent; -import java.awt.event.ComponentListener; -import java.awt.geom.Line2D; -import java.awt.geom.Rectangle2D; -import java.text.DecimalFormat; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import org.apache.commons.math3.util.FastMath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.swing.JPanel; - -public class OverlayPanel extends JPanel implements Listener, ISourceEventProcessor, SettingChangeListener -{ - private static final long serialVersionUID = 1L; - private final static Logger mLog = LoggerFactory.getLogger(OverlayPanel.class); - private final DecimalFormat PPM_FORMATTER = new DecimalFormat( "#.0" ); - - private final static RenderingHints RENDERING_HINTS = new RenderingHints(RenderingHints.KEY_ANTIALIASING, - RenderingHints.VALUE_ANTIALIAS_ON); - - static - { - RENDERING_HINTS.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - } - - private final static BasicStroke DASHED_STROKE = new BasicStroke(0.8f, BasicStroke.CAP_BUTT, - BasicStroke.JOIN_MITER, 5.0f, new float[]{2.0f, 4.0f}, 0.0f); - - private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("000.00000"); - private long mFrequency = 0; - private int mBandwidth = 0; - private Point mCursorLocation = new Point(0, 0); - private boolean mCursorVisible = false; - - private DFTSize mDFTSize = DFTSize.FFT04096; - private int mZoom = 0; - private int mDFTZoomWindowOffset = 0; - - /** - * Colors used by this component - */ - private Color mColorChannelConfig; - private Color mColorChannelConfigProcessing; - private Color mColorChannelConfigSelected; - private Color mColorSpectrumBackground; - private Color mColorSpectrumCursor; - private Color mColorSpectrumLine; - - //Currently visible/displayable channels - private List mVisibleChannels = new CopyOnWriteArrayList<>(); - private List mTrafficChannels = new CopyOnWriteArrayList<>(); - - private ChannelDisplay mChannelDisplay = ChannelDisplay.ALL; - - //Defines the offset at the bottom of the spectral display to account for - //the frequency labels - private double mSpectrumInset = 20.0d; - private LabelSizeManager mLabelSizeMonitor = new LabelSizeManager(); - - private SettingsManager mSettingsManager; - private ChannelModel mChannelModel; - private ChannelProcessingManager mChannelProcessingManager; - - /** - * Translucent overlay panel for displaying channel configurations, - * processing channels, selected channels, frequency labels and lines, and - * a cursor with a frequency readout. - */ - public OverlayPanel(SettingsManager settingsManager, ChannelModel channelModel, ChannelProcessingManager channelProcessingManager) - { - mSettingsManager = settingsManager; - - if(mSettingsManager != null) - { - mSettingsManager.addListener(this); - } - - mChannelModel = channelModel; - - if(mChannelModel != null) - { - mChannelModel.addListener(this::receive); - } - - mChannelProcessingManager = channelProcessingManager; - - if(mChannelProcessingManager != null) - { - mChannelProcessingManager.addChannelEventListener(this::receive); - } - - addComponentListener(mLabelSizeMonitor); - - //Set the background transparent, so the spectrum display can be seen - setOpaque(false); - - //Fetch color settings from settings manager - setColors(); - } - - public void dispose() - { - if(mChannelModel != null) - { - mChannelModel.removeListener(this); - } - - mChannelModel = null; - - mVisibleChannels.clear(); - - if(mSettingsManager != null) - { - mSettingsManager.removeListener(this); - } - - mSettingsManager = null; - } - - /** - * Sets/changes the DFT bin size - */ - public void setDFTSize(DFTSize size) - { - mDFTSize = size; - } - - public ChannelDisplay getChannelDisplay() - { - return mChannelDisplay; - } - - public void setChannelDisplay(ChannelDisplay display) - { - mChannelDisplay = display; - } - - public void setCursorLocation(Point point) - { - mCursorLocation = point; - - repaint(); - } - - public void setCursorVisible(boolean visible) - { - mCursorVisible = visible; - - repaint(); - } - - /** - * Sets the current zoom level (2^zoom) - * - * 0 No Zoom - * 1 2x Zoom - * 2 4x Zoom - * 3 8x Zoom - * 4 16x Zoom - * 5 32x Zoom - * 6 64x Zoom - * - * @param zoom level, 0 - 6. - */ - public void setZoom(int zoom) - { - mZoom = zoom; - - mLabelSizeMonitor.update(); - } - - public void setZoomWindowOffset(int offset) - { - mDFTZoomWindowOffset = offset; - } - - /** - * Fetches the color settings from the settings manager - */ - private void setColors() - { - mColorChannelConfig = getColor(ColorSettingName.CHANNEL_CONFIG); - mColorChannelConfigProcessing = getColor(ColorSettingName.CHANNEL_CONFIG_PROCESSING); - mColorChannelConfigSelected = getColor(ColorSettingName.CHANNEL_CONFIG_SELECTED); - mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR); - mColorSpectrumLine = getColor(ColorSettingName.SPECTRUM_LINE); - mColorSpectrumBackground = getColor(ColorSettingName.SPECTRUM_BACKGROUND); - } - - /** - * Fetches a named color setting from the settings manager. If the setting - * doesn't exist, creates the setting using the defaultColor - */ - private Color getColor(ColorSettingName name) - { - ColorSetting setting = mSettingsManager.getColorSetting(name); - - return setting.getColor(); - } - - /** - * Monitors for setting changes. Colors can be changed by external actions - * and will automatically update in this class - */ - @Override - public void settingChanged(Setting setting) - { - if(setting instanceof ColorSetting) - { - ColorSetting colorSetting = (ColorSetting)setting; - - switch(colorSetting.getColorSettingName()) - { - case CHANNEL_CONFIG: - mColorChannelConfig = colorSetting.getColor(); - break; - case CHANNEL_CONFIG_PROCESSING: - mColorChannelConfigProcessing = colorSetting.getColor(); - break; - case CHANNEL_CONFIG_SELECTED: - mColorChannelConfigSelected = colorSetting.getColor(); - break; - case SPECTRUM_BACKGROUND: - mColorSpectrumBackground = colorSetting.getColor(); - break; - case SPECTRUM_CURSOR: - mColorSpectrumCursor = colorSetting.getColor(); - break; - case SPECTRUM_LINE: - mColorSpectrumLine = colorSetting.getColor(); - break; - default: - break; - } - } - } - - /** - * Renders the channel configs, lines, labels, and cursor - */ - @Override - public void paintComponent(Graphics g) - { - super.paintComponent(g); - - Graphics2D graphics = (Graphics2D)g; - graphics.setBackground(mColorSpectrumBackground); - - graphics.setRenderingHints(RENDERING_HINTS); - - drawFrequencies(graphics); - drawChannels(graphics); - drawCursor(graphics); - } - - /** - * Draws a cursor on the panel, whenever the mouse is hovering over the - * panel - */ - private void drawCursor(Graphics2D graphics) - { - if(mCursorVisible) - { - drawFrequencyLine(graphics, mCursorLocation.x, mColorSpectrumCursor); - - String frequency = CURSOR_FORMAT.format(getFrequencyFromAxis(mCursorLocation.getX()) / 1E6D); - - FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont()); - - Rectangle2D rect = fontMetrics.getStringBounds(frequency, graphics); - - if(mCursorLocation.y > rect.getHeight()) - { - graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y); - } - - if(mZoom != 0) - { - graphics.drawString("Zoom: " + (int)FastMath.pow(2.0, mZoom) + "x", mCursorLocation.x + 17, - mCursorLocation.y + 11); - } - } - } - - /** - * Draws the frequency lines and labels every 10kHz - */ - private void drawFrequencies(Graphics2D graphics) - { - Stroke currentStroke = graphics.getStroke(); - - long minFrequency = getMinDisplayFrequency(); - long maxFrequency = getMaxDisplayFrequency(); - - //Frequency increments for label and tick spacing - int label = mLabelSizeMonitor.getLabelIncrement(graphics); - int major = mLabelSizeMonitor.getMajorTickIncrement(graphics); - int minor = mLabelSizeMonitor.getMinorTickIncrement(graphics); - - //Avoid divide by zero error - if(minor == 0) - { - minor = 1; - } - if(label == 0) - { - label = 1; - } - - //Adjust the start frequency to a multiple of the minor tick spacing - long frequency = minFrequency - (minFrequency % minor); - - while(frequency < maxFrequency) - { - if(frequency % label == 0) - { - drawFrequencyLineAndLabel(graphics, frequency); - } - else if(frequency % major == 0) - { - drawTickLine(graphics, frequency, true); - } - else - { - drawTickLine(graphics, frequency, false); - } - - frequency += minor; - } - } - - /** - * Draws a vertical line and a corresponding frequency label at the bottom - */ - private void drawFrequencyLineAndLabel(Graphics2D graphics, long frequency) - { - double xAxis = getAxisFromFrequency(frequency); - - drawFrequencyLine(graphics, xAxis, mColorSpectrumLine); - - drawTickLine(graphics, frequency, false); - - graphics.setColor(mColorSpectrumLine); - - drawFrequencyLabel(graphics, xAxis, frequency); - } - - /** - * Draws a vertical line at the xaxis - */ - private void drawTickLine(Graphics2D graphics, long frequency, boolean major) - { - graphics.setColor(mColorSpectrumLine); - - double xAxis = getAxisFromFrequency(frequency); - - double start = getSize().getHeight() - mSpectrumInset; - double end = start + (major ? 9.0d : 3.0d); - - graphics.draw(new Line2D.Double(xAxis, start, xAxis, end)); - } - - - /** - * Draws a vertical line at the xaxis - */ - private void drawFrequencyLine(Graphics2D graphics, double xaxis, Color color) - { - graphics.setColor(color); - - graphics.draw(new Line2D.Double(xaxis, 0.0d, xaxis, getSize().getHeight() - mSpectrumInset)); - } - - /** - * Draws a vertical line at the xaxis - */ - private void drawChannelCenterLine(Graphics2D graphics, double xaxis) - { - double height = getSize().getHeight() - mSpectrumInset; - - graphics.setColor(Color.LIGHT_GRAY); - - graphics.draw(new Line2D.Double(xaxis, height * 0.65d, xaxis, height - 1.0d)); - } - - /** - * Draws the Automatic Frequency Control (AFC) channel center offset - */ - private void drawAFC(Graphics2D graphics, double frequencyAxis, double errorAxis, double bandwidth, - int correction, long frequency) - { - double height = getSize().getHeight() - mSpectrumInset; - double verticalAxisTop = height * 0.88d; - double verticalAxisBottom = height * 0.98d; - - double halfBandwidth = bandwidth / 2.0; - double errorEdgeStart = errorAxis - halfBandwidth; - double errorEdgeStop = errorAxis + halfBandwidth; - - graphics.setColor(Color.YELLOW); - - //Horizontal line connecting frequency and error line - graphics.draw(new Line2D.Double(errorEdgeStart, verticalAxisBottom, errorEdgeStop, verticalAxisBottom)); - - //Vertical band edge lines - graphics.draw(new Line2D.Double(errorEdgeStart, verticalAxisTop, errorEdgeStart, verticalAxisBottom)); - graphics.draw(new Line2D.Double(errorEdgeStop, verticalAxisTop, errorEdgeStop, verticalAxisBottom)); - - double ppm = (double)correction / ((double)frequency / 1E6d); - - String label = "PPM " + PPM_FORMATTER.format(ppm) ; - - FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont()); - - Rectangle2D rect = fontMetrics.getStringBounds(label, graphics); - - //Only render the correction value label if the spacing is large enough - if(rect.getWidth() <= bandwidth && rect.getHeight() * 5 <= height) - { - graphics.drawString(label, (float)(errorEdgeStart + 1.0), (float)(verticalAxisBottom - 2.0)); - } - } - - /** - * Returns the x-axis value corresponding to the frequency - */ - private double getAxisFromFrequency(long frequency) - { - double screenWidth = (double)getSize().getWidth(); - - double pixelsPerBin = screenWidth / (double)mDFTSize.getSize(); - - double pixelOffsetToMinDisplayFrequency = pixelsPerBin * 2.0d; - - //Calculate frequency offset from the min frequency - double frequencyOffset = (double)(frequency - getMinDisplayFrequency()); - - //Determine ratio of frequency offset to overall bandwidth - double ratio = frequencyOffset / (double)getDisplayBandwidth(); - - //Apply the ratio to the screen width minus 1 bin width - double screenOffset = screenWidth * ratio; - - return pixelOffsetToMinDisplayFrequency + screenOffset; - } - - /** - * Returns the frequency corresponding to the x-axis value using the current - * zoom level. - */ - public long getFrequencyFromAxis(double xAxis) - { - double width = getSize().getWidth(); - - double offset = xAxis / width; - - long frequency = getMinDisplayFrequency() + FastMath.round((double)getDisplayBandwidth() * offset); - - if(frequency > (getMaxFrequency())) - { - frequency = getMaxFrequency(); - } - - return frequency; - } - - /** - * Draws a frequency label at the x-axis position, at the bottom of the panel - */ - private void drawFrequencyLabel(Graphics2D graphics, double xaxis, long frequency) - { - String label = mLabelSizeMonitor.format(frequency); - - FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont()); - - Rectangle2D rect = fontMetrics.getStringBounds(label, graphics); - - float xOffset = (float)rect.getWidth() / 2; - - graphics.drawString(label, (float)(xaxis - xOffset), (float)(getSize().getHeight() - 2.0f)); - } - - - /** - * Draws visible channel configs as translucent shaded frequency regions - */ - private void drawChannels(Graphics2D graphics) - { - for(Channel channel : mVisibleChannels) - { - if(mChannelDisplay == ChannelDisplay.ALL || (mChannelDisplay == ChannelDisplay.ENABLED && channel.isProcessing())) - { - List tunerChannels = channel.getTunerChannels(); - - for(TunerChannel tunerChannel: tunerChannels) - { - if(tunerChannel.overlaps(getMinDisplayFrequency(), getMaxDisplayFrequency())) - { - //Choose the correct background color to use - if(channel.isSelected()) - { - graphics.setColor(mColorChannelConfigSelected); - } - else if(channel.isProcessing()) - { - graphics.setColor(mColorChannelConfigProcessing); - } - else - { - graphics.setColor(mColorChannelConfig); - } - - double xAxis = getAxisFromFrequency(tunerChannel.getFrequency()); - double width = (double)(tunerChannel.getBandwidth()) / (double)getDisplayBandwidth() * getSize().getWidth(); - - Rectangle2D.Double box = new Rectangle2D.Double(xAxis - (width / 2.0d), 0.0d, width, - getSize().getHeight() - mSpectrumInset); - - //Fill the box with the correct color - graphics.fill(box); - graphics.draw(box); - - //Change to the line color to render the channel name, etc. - graphics.setColor(mColorSpectrumLine); - - //Draw the labels starting at yAxis position 0 - double yAxis = 0; - - //Draw the system label and adjust the y-axis position - String system = channel.hasSystem() ? channel.getSystem() : " "; - - yAxis += drawLabel(graphics, system, this.getFont(), xAxis, yAxis, width); - - //Draw the site label and adjust the y-axis position - String site = channel.hasSite() ? channel.getSite() : " "; - - yAxis += drawLabel(graphics, site, this.getFont(), xAxis, yAxis, width); - - //Draw the channel label and adjust the y-axis position - yAxis += drawLabel(graphics, channel.getName(), this.getFont(), xAxis, yAxis, width); - - //Draw the decoder label - drawLabel(graphics, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), - this.getFont(), xAxis, yAxis, width); - long frequency = tunerChannel.getFrequency(); - double frequencyAxis = getAxisFromFrequency(frequency); - drawChannelCenterLine(graphics, frequencyAxis); - - /* Draw Automatic Frequency Control line */ - int correction = channel.getChannelFrequencyCorrection(); - - if(correction != 0) - { - long error = frequency + correction; - drawAFC(graphics, frequencyAxis, getAxisFromFrequency(error), width, correction, - tunerChannel.getFrequency()); - } - } - } - } - } - } - - - /** - * Draws a textual label at the x/y position, clipping the end of the text - * to fit within the maxwidth value. - * - * @return height of the drawn label - */ - private double drawLabel(Graphics2D graphics, String text, Font font, double x, double baseY, double maxWidth) - { - FontMetrics fontMetrics = graphics.getFontMetrics(font); - - if(text == null || text.isEmpty()) - { - return 0; - } - - Rectangle2D label = fontMetrics.getStringBounds(text, graphics); - - double offset = label.getWidth() / 2.0d; - double y = baseY + label.getHeight(); - - /** - * If the label is wider than the max width, left justify the text and - * clip the end of it - */ - if(offset > (maxWidth / 2.0d)) - { - label.setRect(x - (maxWidth / 2.0d), y - label.getHeight(), maxWidth, label.getHeight()); - - graphics.setClip(label); - - graphics.drawString(text, (float)(x - (maxWidth / 2.0d)), (float)y); - - graphics.setClip(null); - } - else - { - graphics.drawString(text, (float)(x - offset), (float)y); - } - - return label.getHeight(); - } - - /** - * Frequency change event handler - */ - @Override - public void process(SourceEvent event) - { - switch(event.getEvent()) - { - case NOTIFICATION_SAMPLE_RATE_CHANGE: - mBandwidth = event.getValue().intValue(); - mLabelSizeMonitor.update(); - break; - case NOTIFICATION_FREQUENCY_CHANGE: - mFrequency = event.getValue().longValue(); - mLabelSizeMonitor.update(); - break; - default: - break; - } - - /** - * Reset the visible channels list - */ - mVisibleChannels.clear(); - mVisibleChannels.addAll(mChannelModel.getChannelsInFrequencyRange(getMinFrequency(), getMaxFrequency())); - - for(Channel trafficChannel: mTrafficChannels) - { - if(trafficChannel.isWithin(getMinFrequency(), getMaxFrequency())) - { - mVisibleChannels.add(trafficChannel); - } - } - } - - /** - * Channel change event handler - */ - @Override - public void receive(ChannelEvent event) - { - Channel channel = event.getChannel(); - - switch(event.getEvent()) - { - case NOTIFICATION_ADD: - case NOTIFICATION_PROCESSING_START: - if(channel.getChannelType() == ChannelType.TRAFFIC && !mTrafficChannels.contains(channel)) - { - mTrafficChannels.add(channel); - } - if(!mVisibleChannels.contains(channel) && channel.isWithin(getMinFrequency(), getMaxFrequency())) - { - mVisibleChannels.add(channel); - } - break; - case NOTIFICATION_DELETE: - mVisibleChannels.remove(channel); - break; - case NOTIFICATION_PROCESSING_STOP: - case NOTIFICATION_PROCESSING_START_REJECTED: - if(channel.getChannelType() == ChannelType.TRAFFIC) - { - mVisibleChannels.remove(channel); - mTrafficChannels.remove(channel); - } - break; - case NOTIFICATION_CONFIGURATION_CHANGE: - if(mVisibleChannels.contains(channel) && !channel.isWithin(getMinFrequency(), getMaxFrequency())) - { - mVisibleChannels.remove(channel); - } - - if(!mVisibleChannels.contains(channel) && channel.isWithin(getMinFrequency(), getMaxFrequency())) - { - mVisibleChannels.add(channel); - } - break; - default: - break; - } - - repaint(); - } - - public int getBandwidth() - { - return mBandwidth; - } - - /** - * Currently displayed minimum frequency - */ - public long getMinFrequency() - { - return mFrequency - (mBandwidth / 2); - } - - /** - * Currently displayed maximum frequency - */ - public long getMaxFrequency() - { - return mFrequency + (mBandwidth / 2); - } - - public boolean containsFrequency(long frequency) - { - return FastMath.abs(mFrequency - frequency) <= (mBandwidth / 2); - } - - private long getMinDisplayFrequency() - { - double bandwidthPerBin = (double)mBandwidth / (double)(mDFTSize.getSize()); - - return getMinFrequency() + (int)((mDFTZoomWindowOffset) * bandwidthPerBin); - } - - private long getMaxDisplayFrequency() - { - return getMinDisplayFrequency() + getDisplayBandwidth(); - } - - private int getDisplayBandwidth() - { - if(mZoom != 0) - { - return mBandwidth / (int)FastMath.pow(2.0, mZoom); - } - - return mBandwidth; - } - - /** - * Returns a list of channel configs that contain the frequency within their - * min/max frequency settings. - */ - public ArrayList getChannelsAtFrequency(long frequency) - { - ArrayList configs = new ArrayList(); - - for(Channel config : mVisibleChannels) - { - List channels = config.getTunerChannels(); - - for(TunerChannel channel: channels) - { - if(channel != null && channel.getMinFrequency() <= frequency && channel.getMaxFrequency() >= frequency) - { - configs.add(config); - } - } - } - - return configs; - } - - @Override - public void settingDeleted(Setting setting) - { /* not implemented */ } - - /** - * Calculates correct spacing and format for frequency labels and major/minor - * tick lines based on current frequency, bandwidth, zoom and screen size. - */ - public class LabelSizeManager implements ComponentListener - { - private static final double LABEL_FILL_THRESHOLD = 0.5d; - - private DecimalFormat mFrequencyFormat = new DecimalFormat("0.0"); - - private boolean mUpdateRequired = true; - private int mLabelIncrement = 1; - private int mMajorTickIncrement = 1; - private int mMinorTickIncrement = 1; - - public String format(long frequency) - { - return mFrequencyFormat.format((double)frequency / 1E6D); - } - - private void setPrecision(int precision) - { - if(precision < 1) - { - precision = 1; - } - - if(precision > 5) - { - precision = 5; - } - - mFrequencyFormat.setMinimumFractionDigits(precision); - mFrequencyFormat.setMaximumFractionDigits(precision); - } - - private void update(Graphics2D graphics) - { - if(mUpdateRequired) - { - //Set maximum precision as a starting point - setPrecision(5); - - FontMetrics fontMetrics = - graphics.getFontMetrics(OverlayPanel.this.getFont()); - - int maxLabelWidth = fontMetrics.stringWidth(format(getMaxDisplayFrequency())); - - double maxLabels = ((double)OverlayPanel.this.getWidth() * LABEL_FILL_THRESHOLD) / (double)maxLabelWidth; - - //Calculate the next smallest base 10 value for the major increment - int power = (int)FastMath.log10((double)getDisplayBandwidth() / maxLabels); - - //Set the number of decimal places to display in frequency labels - int precision = 5 - power; - - int start = (int)FastMath.pow(10.0, power + 1); - - int minimum = (int)FastMath.pow(10.0, power); - - int labelIncrement = start; - - while(((double)getDisplayBandwidth() / (double)labelIncrement) < maxLabels && labelIncrement >= minimum) - { - labelIncrement /= 2; - precision++; - } - - if(labelIncrement == minimum) - { - precision = 5 - power; - } - - setPrecision(precision); - - mLabelIncrement = labelIncrement; - mMajorTickIncrement = labelIncrement / 2; - mMinorTickIncrement = labelIncrement / 10; - - mUpdateRequired = false; - } - } - - /** - * Forces the display to update the label and frequency display - * calculations - */ - public void update() - { - mUpdateRequired = true; - } - - public int getMajorTickIncrement(Graphics2D graphics) - { - //Check to see if a calculation update is scheduled - update(graphics); - - return mMajorTickIncrement; - } - - public int getMinorTickIncrement(Graphics2D graphics) - { - return mMinorTickIncrement; - } - - public int getLabelIncrement(Graphics2D graphics) - { - return mLabelIncrement; - } - - @Override - public void componentResized(ComponentEvent arg0) - { - update(); - } - - public void componentHidden(ComponentEvent arg0) - { - } - - public void componentMoved(ComponentEvent arg0) - { - } - - public void componentShown(ComponentEvent arg0) - { - } - - } - - public enum ChannelDisplay - { - ALL, ENABLED, NONE; - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/Pausable.java b/src/main/java/io/github/dsheirer/spectrum/Pausable.java deleted file mode 100644 index 7c13837d..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/Pausable.java +++ /dev/null @@ -1,29 +0,0 @@ -/******************************************************************************* - * SDR Trunk - * Copyright (C) 2014 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.spectrum; - -/** - * Interface to add pause action to moving displays like the spectrum and - * waterfall panels - */ -public interface Pausable -{ - public void setPaused( boolean paused ); - - public boolean isPaused(); -} diff --git a/src/main/java/io/github/dsheirer/spectrum/ShowTunerMenuItem.java b/src/main/java/io/github/dsheirer/spectrum/ShowTunerMenuItem.java index 71b4df5a..9a4ac9f9 100644 --- a/src/main/java/io/github/dsheirer/spectrum/ShowTunerMenuItem.java +++ b/src/main/java/io/github/dsheirer/spectrum/ShowTunerMenuItem.java @@ -18,6 +18,7 @@ */ package io.github.dsheirer.spectrum; +import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.source.tuner.Tuner; import io.github.dsheirer.source.tuner.TunerEvent; @@ -50,7 +51,7 @@ public class ShowTunerMenuItem extends JMenuItem public void run() { SystemProperties properties = SystemProperties.getInstance(); - properties.set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true); + properties.set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true); mDiscoveredTunerModel.broadcast(new TunerEvent(mTuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY)); } }); diff --git a/src/main/java/io/github/dsheirer/spectrum/SpectralDisplayPanel.java b/src/main/java/io/github/dsheirer/spectrum/SpectralDisplayPanel.java deleted file mode 100644 index fab5cce9..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/SpectralDisplayPanel.java +++ /dev/null @@ -1,904 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - * **************************************************************************** - */ -package io.github.dsheirer.spectrum; - -import com.jidesoft.swing.JideSplitPane; -import io.github.dsheirer.buffer.INativeBuffer; -import io.github.dsheirer.controller.channel.Channel; -import io.github.dsheirer.controller.channel.ChannelModel; -import io.github.dsheirer.controller.channel.ChannelProcessingManager; -import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter.SmoothingType; -import io.github.dsheirer.dsp.window.WindowType; -import io.github.dsheirer.eventbus.MyEventBus; -import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest; -import io.github.dsheirer.playlist.PlaylistManager; -import io.github.dsheirer.properties.SystemProperties; -import io.github.dsheirer.sample.Listener; -import io.github.dsheirer.settings.ColorSetting.ColorSettingName; -import io.github.dsheirer.settings.ColorSettingMenuItem; -import io.github.dsheirer.settings.SettingsManager; -import io.github.dsheirer.source.ISourceEventProcessor; -import io.github.dsheirer.source.SourceEvent; -import io.github.dsheirer.source.tuner.Tuner; -import io.github.dsheirer.source.tuner.manager.DiscoveredTuner; -import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel; -import io.github.dsheirer.spectrum.OverlayPanel.ChannelDisplay; -import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter; -import io.github.dsheirer.spectrum.converter.DFTResultsConverter; -import io.github.dsheirer.spectrum.menu.AveragingItem; -import io.github.dsheirer.spectrum.menu.DFTSizeItem; -import io.github.dsheirer.spectrum.menu.FFTWindowTypeItem; -import io.github.dsheirer.spectrum.menu.FrameRateItem; -import io.github.dsheirer.spectrum.menu.SmoothingItem; -import io.github.dsheirer.spectrum.menu.SmoothingTypeItem; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.EventQueue; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.awt.event.ComponentEvent; -import java.awt.event.ComponentListener; -import java.awt.event.MouseEvent; -import java.awt.event.MouseWheelEvent; -import java.util.ArrayList; -import java.util.Hashtable; -import net.miginfocom.swing.MigLayout; -import org.apache.commons.math3.util.FastMath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.swing.JCheckBoxMenuItem; -import javax.swing.JComponent; -import javax.swing.JLabel; -import javax.swing.JLayeredPane; -import javax.swing.JMenu; -import javax.swing.JMenuItem; -import javax.swing.JPanel; -import javax.swing.JPopupMenu; -import javax.swing.JScrollPane; -import javax.swing.JSeparator; -import javax.swing.JSlider; -import javax.swing.JSplitPane; -import javax.swing.SwingUtilities; -import javax.swing.event.ChangeEvent; -import javax.swing.event.ChangeListener; -import javax.swing.event.MouseInputAdapter; - -public class SpectralDisplayPanel extends JPanel - implements Listener, ISourceEventProcessor, IDFTWidthChangeProcessor -{ - private static final long serialVersionUID = 1L; - - private final static Logger mLog = LoggerFactory.getLogger(SpectralDisplayPanel.class); - - public static final String FFT_SIZE_PROPERTY = "spectral.display.dft.size"; - public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled"; - public static final int NO_ZOOM = 0; - public static final int MAX_ZOOM = 6; - - private DFTSize mDFTSize = DFTSize.FFT04096; - private int mZoom = 0; - private int mDFTZoomWindowOffset = 0; - - private JScrollPane mScrollPane; - private JLayeredPane mLayeredPanel; - private SpectrumPanel mSpectrumPanel; - private WaterfallPanel mWaterfallPanel; - private OverlayPanel mOverlayPanel; - private ComplexDftProcessor mComplexDftProcessor; - private DFTResultsConverter mDFTConverter; - private ChannelModel mChannelModel; - private ChannelProcessingManager mChannelProcessingManager; - private SettingsManager mSettingsManager; - private DiscoveredTunerModel mDiscoveredTunerModel; - private Tuner mTuner; - - /** - * Spectral Display Panel provides a frequency component display with a - * historical waterfall display and a transparent overlay to show frequency, - * cursor and channel information. - *

- * Mouse scrolling and zooming are supported and the waterfall display can - * be paused. - *

- * Complex sample buffers are processed by a DFTProcessor and the output of - * the DFT is translated to decibels for display in the spectrum and - * waterfall components. - */ - public SpectralDisplayPanel(PlaylistManager playlistManager, SettingsManager settingsManager, DiscoveredTunerModel discoveredTunerModel) - { - mChannelModel = playlistManager.getChannelModel(); - mChannelProcessingManager = playlistManager.getChannelProcessingManager(); - mSettingsManager = settingsManager; - mDiscoveredTunerModel = discoveredTunerModel; - - mSpectrumPanel = new SpectrumPanel(mSettingsManager); - mOverlayPanel = new OverlayPanel(mSettingsManager, mChannelModel, mChannelProcessingManager); - mWaterfallPanel = new WaterfallPanel(mSettingsManager); - - init(); - - loadSettings(); - } - - private void loadSettings() - { - SystemProperties properties = SystemProperties.getInstance(); - - String rawSize = properties.get(FFT_SIZE_PROPERTY, DFTSize.FFT04096.name()); - - DFTSize size = null; - - if(rawSize != null) - { - try - { - size = DFTSize.valueOf(rawSize); - } - catch(Exception e) - { - //Do nothing - } - } - - if(size == null) - { - size = DFTSize.FFT04096; - } - - setDFTSize(size, false); - } - - public void dispose() - { - /* De-register from receiving samples when the window closes */ - clearTuner(); - - mSettingsManager = null; - - mComplexDftProcessor.dispose(); - mComplexDftProcessor = null; - - mDFTConverter.dispose(); - mDFTConverter = null; - - mSpectrumPanel.dispose(); - mSpectrumPanel = null; - - mWaterfallPanel.dispose(); - mWaterfallPanel = null; - - mOverlayPanel.dispose(); - mOverlayPanel = null; - - mTuner = null; - } - - /** - * Queues an FFT size change request. The scheduled executor will apply - * the change when it runs. - */ - public void setDFTSize(DFTSize size, boolean save) - { - mComplexDftProcessor.setDFTSize(size); - mOverlayPanel.setDFTSize(size); - mDFTSize = size; - - if(save) - { - SystemProperties.getInstance().set(FFT_SIZE_PROPERTY, size.name()); - } - - setZoom(0, 0, 0); - } - - public void setDFTSize(DFTSize size) - { - setDFTSize(size, true); - } - - @Override public DFTSize getDFTSize() - { - return mDFTSize; - } - - public int getZoom() - { - return mZoom; - } - - /** - * Sets the current zoom level which will be 2 to the power of zoom (2^zoom) - *

- * 0 No Zoom - * 1 2x Zoom - * 2 4x Zoom - * 3 8x Zoom - * 4 16x Zoom - * 5 32x Zoom - * 6 64x Zoom - * - * @param zoom level, 0 - 6. - * @param frequency under the mouse to maintain while zooming - * @param windowOffset where to maintain the frequency under the mouse - */ - public void setZoom(int zoom, long frequency, double windowOffset) - { - if(zoom < NO_ZOOM) - { - zoom = NO_ZOOM; - } - else if(zoom > MAX_ZOOM) - { - zoom = MAX_ZOOM; - } - - if(zoom != mZoom) - { - mZoom = zoom; - - //Calculate the bin offset that would place the reference frequency - //at the left edge of the zoom window. - double binOffsetToFrequency = getBinOffset(frequency); - - //Calculate the bin offset into the newly sized zoom window that - //would place the frequency in the same proportional window location - //that it was in the previous zoom size - double windowBinOffset = (double)getZoomWindowSizeInBins() * windowOffset; - - //Set the overall offset to place the reference frequency in the - //same location in the newly zoomed window - double offset = binOffsetToFrequency - windowBinOffset; - - mSpectrumPanel.setZoom(mZoom); - mOverlayPanel.setZoom(mZoom); - mWaterfallPanel.setZoom(mZoom); - - setZoomWindowOffset(offset); - } - } - - /** - * Sets the offset (in DFT bins) to the first bin that the zoom window displays - */ - public void setZoomWindowOffset(double offset) - { - if(offset < 0) - { - offset = 0; - } - - if(offset > (mDFTSize.getSize() - getZoomWindowSizeInBins())) - { - offset = mDFTSize.getSize() - getZoomWindowSizeInBins(); - } - - mDFTZoomWindowOffset = (int)offset; - - mSpectrumPanel.setZoomWindowOffset(mDFTZoomWindowOffset); - mOverlayPanel.setZoomWindowOffset(mDFTZoomWindowOffset); - mWaterfallPanel.setZoomWindowOffset(mDFTZoomWindowOffset); - } - - /** - * Calculates the size of the current zoom window in DFT bins - */ - private int getZoomWindowSizeInBins() - { - return mDFTSize.getSize() / getZoomMultiplier(); - } - - public int getZoomMultiplier() - { - return (int)FastMath.pow(2.0, mZoom); - } - - /** - * Calculates the overall offset of the frequency from the current minimum - * frequency in terms of total FFT width - * - * @param frequency - * @return - */ - private double getBinOffset(long frequency) - { - double offset = 0.0; - - if(mOverlayPanel.containsFrequency(frequency)) - { - offset = (double)mDFTSize.getSize() * ((double)(frequency - mOverlayPanel.getMinFrequency()) - / (double)mOverlayPanel.getBandwidth()); - } - - return offset; - } - - /** - * Overrides JComponent method to return false, since we have overlapping - * panels with the spectrum and channel panels - */ - public boolean isOptimizedDrawingEnabled() - { - return false; - } - - private void init() - { - setLayout(new MigLayout("insets 0 0 0 0", "[grow]", "[grow]")); - - /** - * The layered pane holds the overlapping spectrum and channel panels - * and manages the sizing of each panel with the resize listener - */ - mLayeredPanel = new JLayeredPane(); - mLayeredPanel.addComponentListener(new ResizeListener()); - - /** - * Create a mouse adapter to handle mouse events over the spectrum - * and waterfall panels - */ - MouseEventProcessor mouser = new MouseEventProcessor(); - - mOverlayPanel.addMouseListener(mouser); - mOverlayPanel.addMouseMotionListener(mouser); - mOverlayPanel.addMouseWheelListener(mouser); - - //Add the spectrum and channel panels to the layered panel - mLayeredPanel.add(mSpectrumPanel, 0, 0); - mLayeredPanel.add(mOverlayPanel, 1, 0); - - //Create the waterfall - mWaterfallPanel.addMouseListener(mouser); - mWaterfallPanel.addMouseMotionListener(mouser); - mWaterfallPanel.addMouseWheelListener(mouser); - - /* Attempt to set a 50/50 split preferred size for the split pane */ - double totalHeight = - mLayeredPanel.getPreferredSize().getHeight() + mWaterfallPanel.getPreferredSize().getHeight(); - - mLayeredPanel.setPreferredSize( - new Dimension((int)mLayeredPanel.getPreferredSize().getWidth(), (int)(totalHeight / 2.0d))); - - mWaterfallPanel.setPreferredSize( - new Dimension((int)mWaterfallPanel.getPreferredSize().getWidth(), (int)(totalHeight / 2.0d))); - - //Create the split pane to hold the layered pane and the waterfall - JideSplitPane splitPane = new JideSplitPane(JSplitPane.VERTICAL_SPLIT); - splitPane.setDividerSize(5); - splitPane.add(mLayeredPanel); - splitPane.add(mWaterfallPanel); - - mScrollPane = new JScrollPane(splitPane); - - add(mScrollPane, "grow"); - - /** - * Setup DFTProcessor to process samples and register the waterfall and - * spectrum panel to receive the processed dft results - */ - mComplexDftProcessor = new ComplexDftProcessor(); - mDFTConverter = new ComplexDecibelConverter(); - mComplexDftProcessor.addConverter(mDFTConverter); - - mDFTConverter.addListener((DFTResultsListener)mSpectrumPanel); - mDFTConverter.addListener((DFTResultsListener)mWaterfallPanel); - } - - /** - * Receives frequency change events -- primarily from tuner components. - */ - public void process(SourceEvent event) - { - mOverlayPanel.process(event); - } - - /** - * Complex sample buffer receive method - */ - @Override public void receive(INativeBuffer nativeBuffer) - { - mComplexDftProcessor.receive(nativeBuffer); - } - - /** - * Responds to tuner event by deregistering from the current - * complex sample buffer source and registering with the tuner argument. - */ - public void showTuner(Tuner tuner) - { - clearTuner(); - - mComplexDftProcessor.clearBuffer(); - - mComplexDftProcessor.start(); - - mTuner = tuner; - - if(mTuner != null) - { - //Register the dft processor to receive samples from the tuner - mTuner.getTunerController().addBufferListener(mComplexDftProcessor); - - //Verify that the tuner is still non-null, in case it encountered an error on starting sample stream - if(mTuner != null) - { - //Register to receive frequency change events - mTuner.getTunerController().addListener(this); - - mSpectrumPanel.setSampleSize(mTuner.getSampleSize()); - - //Fire frequency and sample rate change events so that the spectrum - //and overlay panels can synchronize - process(SourceEvent.frequencyChange(null, mTuner.getTunerController().getFrequency())); - process(SourceEvent.sampleRateChange(mTuner.getTunerController().getSampleRate())); - } - } - } - - /** - * Tuner de-selection cleanup method - */ - public void clearTuner() - { - if(mTuner != null) - { - //Deregister for frequency change events from the tuner - mTuner.getTunerController().removeListener(SpectralDisplayPanel.this); - - //Deregister the dft processor from receiving samples - mTuner.getTunerController().removeBufferListener(mComplexDftProcessor); - mTuner = null; - } - - mComplexDftProcessor.stop(); - mComplexDftProcessor.clearBuffer(); - mSpectrumPanel.clearSpectrum(); - mWaterfallPanel.clearWaterfall(); - } - - /** - * Currently displayed tuner - */ - public Tuner getTuner() - { - return mTuner; - } - - /** - * Monitors the sizing of the layered pane and resizes the spectrum and - * channel panels whenever the layered pane is resized - */ - public class ResizeListener implements ComponentListener - { - @Override public void componentResized(ComponentEvent e) - { - Component c = e.getComponent(); - - mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight()); - mOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight()); - } - - @Override public void componentHidden(ComponentEvent arg0) - { - } - - @Override public void componentMoved(ComponentEvent arg0) - { - } - - @Override public void componentShown(ComponentEvent arg0) - { - } - } - - /** - * Mouse event handler for the spectral display panel. - */ - public class MouseEventProcessor extends MouseInputAdapter - { - private int mDFTZoomWindowOffsetAtDragStart = 0; - private int mDragStartX = 0; - private double mPixelsPerBin; - - public MouseEventProcessor() - { - } - - @Override public void mouseWheelMoved(MouseWheelEvent e) - { - int zoom = mZoom - e.getWheelRotation(); - - long frequency = mOverlayPanel.getFrequencyFromAxis(e.getX()); - - double windowOffset = (double)e.getX() / (double)getWidth(); - - setZoom(zoom, frequency, windowOffset); - } - - @Override public void mouseMoved(MouseEvent event) - { - update(event); - } - - @Override public void mouseDragged(MouseEvent event) - { - update(event); - - int dragDistance = mDragStartX - event.getX(); - - double binDistance = (double)dragDistance / mPixelsPerBin; - - int offset = (int)(mDFTZoomWindowOffsetAtDragStart + binDistance); - - if(offset < 0) - { - offset = 0; - } - - int maxOffset = mDFTSize.getSize() - (mDFTSize.getSize() / getZoomMultiplier()); - - if(offset > maxOffset) - { - offset = maxOffset; - } - - setZoomWindowOffset(offset); - } - - @Override public void mousePressed(MouseEvent e) - { - mDragStartX = e.getX(); - - mDFTZoomWindowOffsetAtDragStart = mDFTZoomWindowOffset; - - mPixelsPerBin = (double)getWidth() / ((double)(mDFTSize.getSize()) / (double)getZoomMultiplier()); - } - - /** - * Updates the cursor display while the mouse is performing actions - */ - private void update(MouseEvent event) - { - if(event.getComponent() == mOverlayPanel) - { - mOverlayPanel.setCursorLocation(event.getPoint()); - } - else - { - mWaterfallPanel.setCursorLocation(event.getPoint()); - mWaterfallPanel.setCursorFrequency(mOverlayPanel.getFrequencyFromAxis(event.getPoint().x)); - } - } - - @Override public void mouseEntered(MouseEvent e) - { - if(e.getComponent() == mOverlayPanel) - { - mOverlayPanel.setCursorVisible(true); - } - else - { - mWaterfallPanel.setCursorVisible(true); - } - } - - @Override public void mouseExited(MouseEvent e) - { - mOverlayPanel.setCursorVisible(false); - mWaterfallPanel.setCursorVisible(false); - } - - /** - * Displays the context menu. - */ - @Override public void mouseClicked(MouseEvent event) - { - if(SwingUtilities.isRightMouseButton(event)) - { - JPopupMenu contextMenu = new JPopupMenu(); - - if(event.getComponent() == mWaterfallPanel) - { - contextMenu.add(new PauseItem(mWaterfallPanel, "Pause")); - contextMenu.add(new JSeparator()); - } - - long frequency = mOverlayPanel.getFrequencyFromAxis(event.getX()); - - if(event.getComponent() == mOverlayPanel) - { - ArrayList channels = mOverlayPanel.getChannelsAtFrequency(frequency); - - JMenu channelMenu = new JMenu("Channels"); - for(Channel channel : channels) - { - JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle()); - viewChannel.addActionListener( - e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel))); - channelMenu.add(viewChannel); - } - - contextMenu.add(channelMenu); - - if(!channels.isEmpty()) - { - contextMenu.add(new JSeparator()); - } - } - - /** - * Color Menus - */ - JMenu colorMenu = new JMenu("Color"); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG_PROCESSING)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG_SELECTED)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_CURSOR)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_LINE)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_BACKGROUND)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_GRADIENT_BOTTOM)); - - colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_GRADIENT_TOP)); - - contextMenu.add(colorMenu); - - /** - * Display items: fft and frame rate - */ - JMenu displayMenu = new JMenu("Display"); - contextMenu.add(displayMenu); - - if(event.getComponent() != mWaterfallPanel) - { - /** - * Averaging menu - */ - JMenu averagingMenu = new JMenu("Averaging"); - averagingMenu.add(new AveragingItem(mSpectrumPanel, 4)); - displayMenu.add(averagingMenu); - - /** - * Channel Display setting menu - */ - JMenu channelDisplayMenu = new JMenu("Channel"); - - channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.ALL)); - channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.ENABLED)); - channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.NONE)); - - displayMenu.add(channelDisplayMenu); - } - - /** - * FFT width - */ - JMenu fftWidthMenu = new JMenu("FFT Width"); - displayMenu.add(fftWidthMenu); - - for(DFTSize width : DFTSize.values()) - { - fftWidthMenu.add(new DFTSizeItem(SpectralDisplayPanel.this, width)); - } - - /** - * DFT Processor Frame Rate - */ - JMenu frameRateMenu = new JMenu("Frame Rate"); - displayMenu.add(frameRateMenu); - - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 14)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 16)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 18)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 20)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 25)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 30)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 40)); - frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 50)); - - /** - * FFT Window Type - */ - JMenu fftWindowType = new JMenu("Window Type"); - displayMenu.add(fftWindowType); - - for(WindowType type : WindowType.values()) - { - fftWindowType.add(new FFTWindowTypeItem(mComplexDftProcessor, type)); - } - - if(event.getComponent() != mWaterfallPanel) - { - /** - * Smoothing menu - */ - JMenu smoothingMenu = new JMenu("Smoothing"); - - if(mSpectrumPanel.getSmoothingType() != SmoothingType.NONE) - { - smoothingMenu.add(new SmoothingItem(mSpectrumPanel, 5)); - smoothingMenu.add(new JSeparator()); - } - smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.GAUSSIAN)); - smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.TRIANGLE)); - smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.RECTANGLE)); - smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.NONE)); - - displayMenu.add(smoothingMenu); - } - - /* - * Zoom menu - */ - JMenuItem zoomMenu = new JMenu("Zoom"); - - double windowOffset = (double)event.getX() / (double)getWidth(); - - zoomMenu.add(new ZoomItem(frequency, windowOffset)); - - contextMenu.add(zoomMenu); - - if(mTuner != null) - { - contextMenu.add(new JSeparator()); - contextMenu.add(new DisableSpectrumWaterfallMenuItem(SpectralDisplayPanel.this)); - } - - boolean separatorAdded = false; - - for(DiscoveredTuner discoveredTuner : mDiscoveredTunerModel.getAvailableTuners()) - { - if(mTuner == null || mTuner != discoveredTuner.getTuner()) - { - if(!separatorAdded) - { - contextMenu.add(new JSeparator()); - separatorAdded = true; - } - - contextMenu.add(new ShowTunerMenuItem(mDiscoveredTunerModel, discoveredTuner.getTuner())); - } - } - - if(contextMenu != null) - { - if(event.getComponent() == mOverlayPanel) - { - contextMenu.show(mOverlayPanel, event.getX(), event.getY()); - } - else - { - contextMenu.show(mWaterfallPanel, event.getX(), event.getY()); - } - } - } - } - } - - public class PauseItem extends JCheckBoxMenuItem - { - private static final long serialVersionUID = 1L; - - private Pausable mPausable; - - public PauseItem(Pausable pausable, String label) - { - super(label); - - final boolean paused = pausable.isPaused(); - - setSelected(paused); - - mPausable = pausable; - - addActionListener(new ActionListener() - { - @Override public void actionPerformed(ActionEvent e) - { - EventQueue.invokeLater(new Runnable() - { - @Override public void run() - { - mPausable.setPaused(!paused); - } - }); - } - }); - } - } - - public class ZoomItem extends JSlider - { - private static final long serialVersionUID = 1L; - - private long mFrequency; - private double mWindowOffset; - - public ZoomItem(long frequency, double windowOffset) - { - super(NO_ZOOM, MAX_ZOOM, mZoom); - - mFrequency = frequency; - mWindowOffset = windowOffset; - - Hashtable labels = new Hashtable<>(); - labels.put(0, new JLabel("1x")); - labels.put(1, new JLabel("2x")); - labels.put(2, new JLabel("4x")); - labels.put(3, new JLabel("8x")); - labels.put(4, new JLabel("16x")); - labels.put(5, new JLabel("32x")); - labels.put(6, new JLabel("64x")); - - setLabelTable(labels); - - setMajorTickSpacing(1); - setMinorTickSpacing(1); - setPaintTicks(true); - setPaintLabels(true); - - this.addChangeListener(new ChangeListener() - { - @Override public void stateChanged(ChangeEvent e) - { - setZoom(getValue(), mFrequency, mWindowOffset); - } - }); - } - } - - public class ChannelDisplayItem extends JCheckBoxMenuItem - { - private static final long serialVersionUID = 1L; - - private OverlayPanel mOverlayPanel; - private ChannelDisplay mChannelDisplay; - - public ChannelDisplayItem(OverlayPanel panel, ChannelDisplay display) - { - super(display.name()); - - mOverlayPanel = panel; - - mChannelDisplay = display; - - setSelected(mOverlayPanel.getChannelDisplay() == mChannelDisplay); - - addActionListener(new ActionListener() - { - @Override public void actionPerformed(ActionEvent e) - { - EventQueue.invokeLater(new Runnable() - { - @Override public void run() - { - mOverlayPanel.setChannelDisplay(mChannelDisplay); - } - }); - } - }); - } - } -} \ No newline at end of file diff --git a/src/main/java/io/github/dsheirer/spectrum/SpectrumFrame.java b/src/main/java/io/github/dsheirer/spectrum/SpectrumFrame.java deleted file mode 100644 index 7a257a79..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/SpectrumFrame.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2022 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.spectrum; - -import io.github.dsheirer.playlist.PlaylistManager; -import io.github.dsheirer.settings.SettingsManager; -import io.github.dsheirer.source.tuner.Tuner; -import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel; -import net.miginfocom.swing.MigLayout; - -import javax.swing.JFrame; -import java.awt.EventQueue; -import java.awt.event.WindowEvent; -import java.awt.event.WindowListener; - -public class SpectrumFrame extends JFrame implements WindowListener -{ - private static final long serialVersionUID = 1L; - - private SpectralDisplayPanel mSpectralDisplayPanel; - - public SpectrumFrame(PlaylistManager playlistManager, SettingsManager settingsManager, - DiscoveredTunerModel discoveredTunerModel, Tuner tuner) - { - setTitle("SDRTRunk [" + tuner.getPreferredName() + "]"); - setBounds(100, 100, 1280, 600); - setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); - - setLayout(new MigLayout("insets 0 0 0 0", "[grow]", "[grow]")); - - mSpectralDisplayPanel = new SpectralDisplayPanel(playlistManager, settingsManager, discoveredTunerModel); - - mSpectralDisplayPanel.showTuner(tuner); - add(mSpectralDisplayPanel, "grow"); - - /* Register a shutdown listener */ - this.addWindowListener(this); - - EventQueue.invokeLater(new Runnable() - { - public void run() - { - setVisible(true); - } - }); - } - - @Override - public void windowClosed(WindowEvent arg0) - { - mSpectralDisplayPanel.dispose(); - } - - @Override - public void windowActivated(WindowEvent arg0) - { - } - - @Override - public void windowClosing(WindowEvent arg0) - { - } - - @Override - public void windowDeactivated(WindowEvent arg0) - { - } - - @Override - public void windowDeiconified(WindowEvent arg0) - { - } - - @Override - public void windowIconified(WindowEvent arg0) - { - } - - @Override - public void windowOpened(WindowEvent arg0) - { - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/WaterfallColorModel.java b/src/main/java/io/github/dsheirer/spectrum/WaterfallColorModel.java deleted file mode 100644 index dab08be0..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/WaterfallColorModel.java +++ /dev/null @@ -1,94 +0,0 @@ -/******************************************************************************* - * SDR Trunk - * Copyright (C) 2014 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.spectrum; - -import java.awt.image.IndexColorModel; - - -public class WaterfallColorModel -{ - public static IndexColorModel getDefaultColorModel() - { - int bitDepth = 8; - int indexSize = 256; - - byte[] red = new byte[ indexSize ]; - byte[] green = new byte[ indexSize ]; - byte[] blue = new byte[ indexSize ]; - - //Background noise - for( int x = 0; x < 16; x++ ) //Blue - { - red[ x ] = (byte)0; - - green[ x ] = (byte)0; - - blue[ x ] = (byte)127; - } - - for( int x = 16; x < 32; x++ ) //Blue - { - red[ x ] = (byte)0; - - green[ x ] = (byte)0; - - blue[ x ] = (byte)191; - } - - int r = 0; - int g = 0; - int b = 191; - - for( int x = 32; x < 60; x++ ) - { - red[ x ] = (byte)r; - r += 9; - - green[ x ] = (byte)g; - g += 9; - - blue[ x ] = (byte)b; - b -= 6; - } - - r = 255; - g = 255; - b = 0; - - for( int x = 60; x < 188; x++ ) //Yellow - { - red[ x ] = (byte)r; - - green[ x ] = (byte)g; - g -= 2; - - blue[ x ] = (byte)b; - } - - for( int x = 188; x < 256; x++ ) //Red - { - red[ x ] = (byte)255; - - green[ x ] = (byte)0; - - blue[ x ] = (byte)0; - } - - return new IndexColorModel( bitDepth, indexSize, red, green, blue ); - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/WaterfallPanel.java b/src/main/java/io/github/dsheirer/spectrum/WaterfallPanel.java deleted file mode 100644 index 4517a453..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/WaterfallPanel.java +++ /dev/null @@ -1,425 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2022 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.spectrum; - -import io.github.dsheirer.settings.ColorSetting; -import io.github.dsheirer.settings.ColorSetting.ColorSettingName; -import io.github.dsheirer.settings.Setting; -import io.github.dsheirer.settings.SettingChangeListener; -import io.github.dsheirer.settings.SettingsManager; -import java.awt.Color; -import java.awt.EventQueue; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Point; -import java.awt.geom.Line2D; -import java.awt.image.BufferedImage; -import java.awt.image.DataBufferByte; -import java.awt.image.IndexColorModel; -import java.text.DecimalFormat; -import java.util.Arrays; -import org.apache.commons.math3.util.FastMath; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.swing.JPanel; - -public class WaterfallPanel extends JPanel implements DFTResultsListener, - Pausable, - SettingChangeListener -{ - private static final long serialVersionUID = 1L; - - private final static Logger mLog = - LoggerFactory.getLogger(WaterfallPanel.class); - - private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("0.00000"); - private static final String PAUSED = "PAUSED - Right Click to Unpause"; - private static final String DISABLED = "DISABLED - Right Click to Select a Tuner"; - - private byte[] mPixels; - private byte[] mPausedPixels; - private int mDFTSize = 4096; - private int mImageHeight = 700; - private IndexColorModel mColorModel = WaterfallColorModel.getDefaultColorModel(); - private Color mColorSpectrumCursor; - private BufferedImage mVisibleImage; - private byte[] mVisiblePixels; - private boolean mIsDisposed = false; - - private Point mCursorLocation = new Point(0, 0); - private boolean mCursorVisible = false; - private long mCursorFrequency = 0; - private boolean mPaused = false; - private boolean mDisabled = true; - private int mZoom = 0; - private int mDFTZoomWindowOffset = 0; - - private SettingsManager mSettingsManager; - - /** - * Displays a scrolling window of multiple DFT frequency bin outputs over - * time. Maps DFT frequency bin decibel values into a 256 bucket color map - * for display. - * - * @param settingsManager - */ - public WaterfallPanel(SettingsManager settingsManager) - { - super(); - mSettingsManager = settingsManager; - mSettingsManager.addListener(this); - mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR); - reset(); - } - - /** - * Prepares this instance for disposal - */ - public void dispose() - { - if(mSettingsManager != null) - { - mSettingsManager.removeListener(this); - } - - mSettingsManager = null; - mIsDisposed = true; - } - - /** - * Resets the byte backing arrays when the DFT point size has changed - */ - private void reset() - { - mPixels = new byte[mDFTSize * mImageHeight]; - - if(mPaused) - { - mPausedPixels = mPixels.clone(); - } - - repaint(); - } - - /** - * Pausable interface - pauses updates to the waterfall - */ - public void setPaused(boolean paused) - { - if(paused) - { - mPausedPixels = mPixels.clone(); - } - - mPaused = paused; - - repaint(); - } - - /** - * Returns current pause state - * - * @return true if paused, false otherwise - */ - public boolean isPaused() - { - return mPaused; - } - - /** - * Indicates if the waterfall is currently disabled. - */ - public boolean isDisabled() - { - return mDisabled; - } - - /** - * Sets the current zoom level (2^zoom) - * - * 0 No Zoom - * 1 2x Zoom - * 2 4x Zoom - * 3 8x Zoom - * 4 16x Zoom - * 5 32x Zoom - * 6 64x Zoom - * - * @param zoom level, 0 - 6. - */ - public void setZoom(int zoom) - { - mZoom = zoom; - } - - /** - * Multiplier for the current zoom level - */ - private int getZoomMultiplier() - { - return (int) FastMath.pow(2.0, mZoom); - } - - /** - * Sets the zoom window offset from zero - * - * @param offset in DFT bins - */ - public void setZoomWindowOffset(int offset) - { - mDFTZoomWindowOffset = offset; - } - - /** - * Fetches a named color setting from the settings manager. If the setting - * doesn't exist, creates the setting using the defaultColor - */ - private Color getColor(ColorSettingName name) - { - ColorSetting setting = mSettingsManager.getColorSetting(name); - - return setting.getColor(); - } - - /** - * Monitors for setting changes. Colors can be changed by external actions - * and will automatically update in this class - */ - @Override - public void settingChanged(Setting setting) - { - if(setting instanceof ColorSetting colorSetting) - { - if(colorSetting.getColorSettingName() == ColorSettingName.SPECTRUM_CURSOR) - { - mColorSpectrumCursor = colorSetting.getColor(); - } - } - } - - @Override - public void settingDeleted(Setting setting) - { /* Not implemented */ } - - /** - * Sets the display location of the cursor. Cursor location monitoring is - * handled external to this class. - * - * @param point - */ - public void setCursorLocation(Point point) - { - mCursorLocation = point; - repaint(); - } - - /** - * Sets the current cursor display frequency. Cursor location frequency - * monitoring is handled external to this class. - * - * @param frequency - */ - public void setCursorFrequency(long frequency) - { - mCursorFrequency = frequency; - } - - /** - * Toggles the visibility of the cursor - * - * @param visible - */ - public void setCursorVisible(boolean visible) - { - mCursorVisible = visible; - repaint(); - } - - /** - * Copies the DFT bins that are visible at the current zoom level and offset into an image that is sized to hold - * only those bins. The whole waterfall can't be scaled to the zoomed width and clipped to the panel because the - * XRender pipeline silently renders nothing once the scaled width of the source image reaches 65,536 pixels. - * - * The image's pixel array is accessed directly, so Java2D doesn't cache it and each paint renders the current bins. - * - * @param multiplier - current zoom multiplier - */ - private void updateVisibleImage(int multiplier) - { - int bins = mDFTSize / multiplier; - - if(mVisibleImage == null || mVisibleImage.getWidth() != bins) - { - mVisibleImage = new BufferedImage(bins, mImageHeight, BufferedImage.TYPE_BYTE_INDEXED, mColorModel); - mVisiblePixels = ((DataBufferByte)mVisibleImage.getRaster().getDataBuffer()).getData(); - } - - byte[] source = mPaused ? mPausedPixels : mPixels; - - //The display is shifted one bin to the left, to align with the spectrum. Bins past the end are set to zero. - int firstBin = Math.min(mDFTZoomWindowOffset + 1, mDFTSize); - int length = Math.min(bins, mDFTSize - firstBin); - - for(int row = 0; row < mImageHeight; row++) - { - int destination = row * bins; - System.arraycopy(source, (row * mDFTSize) + firstBin, mVisiblePixels, destination, length); - Arrays.fill(mVisiblePixels, destination + length, destination + bins, (byte)0); - } - } - - /** - * Renders the screen at each refresh - */ - public void paintComponent(Graphics g) - { - super.paintComponent(g); - - updateVisibleImage(getZoomMultiplier()); - g.drawImage(mVisibleImage, 0, 0, getWidth(), mImageHeight, null); - Graphics2D graphics = (Graphics2D)g; - graphics.setColor(mColorSpectrumCursor); - - if(mCursorVisible) - { - graphics.draw(new Line2D.Float(mCursorLocation.x, 0, mCursorLocation.x, (float)(getSize().getHeight()))); - String frequency = CURSOR_FORMAT.format(mCursorFrequency / 1000000.0D); - graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y); - } - - if(mDisabled) - { - graphics.drawString(DISABLED, 20, 20); - } - else if(mPaused) - { - graphics.drawString(PAUSED, 20, 20); - } - - paintZoomIndicator(graphics); - graphics.dispose(); - } - - /** - * When zoom level is greater than zero, paints a small indicator at the - * bottom center of the screen showing the location of the zoom window - * within the overall DFT results window - */ - private void paintZoomIndicator(Graphics2D graphics) - { - if(mZoom != 0) - { - int width = getWidth() / 4; - int x = (getWidth() / 2) - (width / 2); - - //Draw the outer window - graphics.drawRect(x, getHeight() - 12, width, 10); - int zoomWidth = width / getZoomMultiplier(); - int windowOffset = 0; - - if(mDFTZoomWindowOffset != 0) - { - windowOffset = (int)(((double)mDFTZoomWindowOffset / (double)mDFTSize) * width); - } - - //Draw the zoom window - graphics.fillRect(x + windowOffset, getHeight() - 12, zoomWidth, 10); - - //Draw the zoom text - graphics.drawString("Zoom: " + getZoomMultiplier() + "x", x + width + 3, getHeight() - 2); - } - } - - /** - * Implements the DFT results listener interface method. This is the primary method for receiving new frequency bin results. - */ - @Override - public void receive(float[] update) - { - mDisabled = false; - - byte[] newPixels = new byte[update.length]; - - /** - * Find the average value and scale the display to it - */ - double sum = 0.0d; - - for(int x = 0; x < update.length - 1; x++) - { - sum += update[x]; - } - - float average = (float)(sum / (double)update.length - 1); - float scale = 256.0f / average; - - for(int x = 0; x < update.length - 1; x++) - { - float value = (average - update[x]) * scale; - - if(value < 0) - { - newPixels[x] = 0; - } - else if(value > 255) - { - newPixels[x] = (byte)255; - } - else - { - newPixels[x] = (byte)value; - } - } - - //Task the swing event thread to add the new pixels to the pixel array and update the display - EventQueue.invokeLater(() -> { - if(!mIsDisposed) - { - //If our FFT size changes, reset our pixel map - if(mDFTSize != newPixels.length) - { - mDFTSize = newPixels.length; - reset(); - } - - //Move the pixels down a row and add in the new pixels row - System.arraycopy(mPixels, 0, mPixels, mDFTSize, mPixels.length - mDFTSize); - System.arraycopy(newPixels, 0, mPixels, 0, newPixels.length); - repaint(); - } - }); - } - - public void clearWaterfall() - { - mDisabled = true; - - EventQueue.invokeLater(() -> { - Arrays.fill(mPixels, (byte)0); - - if(mPausedPixels != null) - { - Arrays.fill(mPausedPixels, (byte)0); - } - - repaint(); - }); - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/converter/ComplexDecibelConverter.java b/src/main/java/io/github/dsheirer/spectrum/converter/ComplexDecibelConverter.java index cce8d58e..b66dbc6c 100644 --- a/src/main/java/io/github/dsheirer/spectrum/converter/ComplexDecibelConverter.java +++ b/src/main/java/io/github/dsheirer/spectrum/converter/ComplexDecibelConverter.java @@ -67,9 +67,22 @@ public class ComplexDecibelConverter extends DFTResultsConverter } public static float[] convert(float[] results) + { + return convert(results, 1.0f / (float)(results.length / 2)); + } + + /** + * Converts the output of the JTransforms FloatFFT_1D.complexForward() calculation into decibels, with each bin's + * power (magnitude squared) multiplied by the scale factor, and swaps the upper and lower halves of the results + * so the lowest frequency is first. + * @param results interleaved complex DFT results + * @param powerScale to multiply each bin's power by before converting to decibels + * @return decibel value for each bin, half the length of the results + */ + public static float[] convert(float[] results, float powerScale) { int halfResults = results.length / 2; - float dftBinSizeScalor = 1.0f / (float) halfResults; + float dftBinSizeScalor = powerScale; float[] processed = new float[halfResults]; int middle = processed.length / 2; diff --git a/src/main/java/io/github/dsheirer/spectrum/converter/FullScaleDecibelConverter.java b/src/main/java/io/github/dsheirer/spectrum/converter/FullScaleDecibelConverter.java new file mode 100644 index 00000000..f8772b4e --- /dev/null +++ b/src/main/java/io/github/dsheirer/spectrum/converter/FullScaleDecibelConverter.java @@ -0,0 +1,57 @@ +/* + * ***************************************************************************** + * 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.spectrum.converter; + +/** + * Converts complex DFT results to decibels relative to full scale (dBFS). A complex tone with an amplitude of 1.0 + * (the full scale of the normalized samples) centered in a DFT bin converts to 0 dBFS. + * + * Use with a ComplexDftProcessor whose window is scaled to unity coherent gain (setUnityGainWindow(true)). With + * unity coherent gain the DFT magnitude of a full scale tone equals the DFT size N, so each bin's power is divided + * by N squared. + * + * Noise spreads across the bins, so the noise floor of each bin is lower than the total noise power by the DFT + * processing gain: 10 * log10(N / window noise bandwidth in bins). Doubling the DFT size lowers the noise floor by + * about 3 dB while tones stay at the same level. + */ +public class FullScaleDecibelConverter extends DFTResultsConverter +{ + /** + * Constructs an instance. + */ + public FullScaleDecibelConverter() + { + } + + /** + * Converts interleaved complex DFT results to dBFS, with the lowest frequency first. + */ + public static float[] convert(float[] results) + { + double size = results.length / 2.0; + return ComplexDecibelConverter.convert(results, (float)(1.0 / (size * size))); + } + + @Override + public void receive(float[] results) + { + dispatch(convert(results)); + } +} diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/AveragingItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/AveragingItem.java deleted file mode 100644 index 3d05a407..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/AveragingItem.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.github.dsheirer.spectrum.menu; - -import io.github.dsheirer.spectrum.SpectralDisplayAdjuster; - -import javax.swing.*; -import javax.swing.event.ChangeEvent; -import javax.swing.event.ChangeListener; -import java.awt.event.MouseEvent; -import java.awt.event.MouseListener; - -public class AveragingItem extends JSlider implements ChangeListener -{ - private static final long serialVersionUID = 1L; - - private SpectralDisplayAdjuster mAdjuster; - private int mDefaultValue; - - public AveragingItem( SpectralDisplayAdjuster adjuster, int defaultValue ) - { - super( JSlider.HORIZONTAL, 1, 20, adjuster.getAveraging() ); - mDefaultValue = defaultValue; - - mAdjuster = adjuster; - - setMajorTickSpacing( 5 ); - setMinorTickSpacing( 1 ); - setPaintTicks( true ); - setPaintLabels( true ); - - addChangeListener( this ); - - addMouseListener( new MouseListener() - { - @Override - public void mouseClicked( MouseEvent event ) - { - if( event.getClickCount() == 2 ) - { - AveragingItem.this.setValue( mDefaultValue ); - } - } - - public void mouseReleased( MouseEvent arg0 ) {} - public void mousePressed( MouseEvent arg0 ) {} - public void mouseExited( MouseEvent arg0 ) {} - public void mouseEntered( MouseEvent arg0 ) {} - } ); - } - - @Override - public void stateChanged( ChangeEvent event ) - { - int value = ((JSlider)event.getSource()).getValue(); - - mAdjuster.setAveraging( value ); - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/DFTSizeItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/DFTSizeItem.java deleted file mode 100644 index f8973a82..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/DFTSizeItem.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.dsheirer.spectrum.menu; - -import io.github.dsheirer.spectrum.DFTSize; -import io.github.dsheirer.spectrum.IDFTWidthChangeProcessor; - -import javax.swing.*; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -public class DFTSizeItem extends JCheckBoxMenuItem -{ - private static final long serialVersionUID = 1L; - - private IDFTWidthChangeProcessor mDFTProcessor; - private DFTSize mDFTSize; - - public DFTSizeItem( IDFTWidthChangeProcessor processor, DFTSize size ) - { - super( size.getLabel() ); - - mDFTProcessor = processor; - mDFTSize = size; - - if( processor.getDFTSize() == mDFTSize ) - { - setSelected( true ); - } - - addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) - { - mDFTProcessor.setDFTSize( mDFTSize ); - } - } ); - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/FFTWindowTypeItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/FFTWindowTypeItem.java deleted file mode 100644 index 2ae7437f..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/FFTWindowTypeItem.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2022 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.spectrum.menu; - -import io.github.dsheirer.dsp.window.WindowType; -import io.github.dsheirer.spectrum.ComplexDftProcessor; - -import javax.swing.JCheckBoxMenuItem; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -public class FFTWindowTypeItem extends JCheckBoxMenuItem -{ - private static final long serialVersionUID = 1L; - - private ComplexDftProcessor mComplexDftProcessor; - private WindowType mWindowType; - - public FFTWindowTypeItem(ComplexDftProcessor processor, WindowType windowType) - { - super(windowType.toString()); - - mComplexDftProcessor = processor; - mWindowType = windowType; - - if(processor.getWindowType() == mWindowType) - { - setSelected(true); - } - - addActionListener(new ActionListener() - { - @Override public void actionPerformed(ActionEvent arg0) - { - mComplexDftProcessor.setWindowType(mWindowType); - } - }); - } -} - diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/FrameRateItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/FrameRateItem.java deleted file mode 100644 index 3e0cc53b..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/FrameRateItem.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * ***************************************************************************** - * Copyright (C) 2014-2022 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.spectrum.menu; - -import io.github.dsheirer.spectrum.ComplexDftProcessor; - -import javax.swing.*; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -public class FrameRateItem extends JCheckBoxMenuItem -{ - private static final long serialVersionUID = 1L; - - private ComplexDftProcessor mComplexDftProcessor; - private int mFrameRate; - - public FrameRateItem(ComplexDftProcessor processor, int frameRate ) - { - super( String.valueOf( frameRate ) ); - - mComplexDftProcessor = processor; - mFrameRate = frameRate; - - if( processor.getFrameRate() == mFrameRate ) - { - setSelected( true ); - } - - addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) - { - mComplexDftProcessor.setFrameRate( mFrameRate ); - } - } ); - } -} - diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingItem.java deleted file mode 100644 index 71d60273..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingItem.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * ****************************************************************************** - * sdrtrunk - * Copyright (C) 2014-2019 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.spectrum.menu; - -import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter; -import io.github.dsheirer.spectrum.SpectralDisplayAdjuster; - -import javax.swing.JLabel; -import javax.swing.JSlider; -import javax.swing.event.ChangeEvent; -import javax.swing.event.ChangeListener; -import java.awt.event.MouseEvent; -import java.awt.event.MouseListener; -import java.util.Hashtable; - -public class SmoothingItem extends JSlider implements ChangeListener -{ - private static final long serialVersionUID = 1L; - - private SpectralDisplayAdjuster mAdjuster; - private int mDefaultValue; - - public SmoothingItem(SpectralDisplayAdjuster adjuster, int defaultValue) - { - super(JSlider.HORIZONTAL, - SmoothingFilter.SMOOTHING_MINIMUM, - SmoothingFilter.SMOOTHING_MAXIMUM, - adjuster.getSmoothing()); - - mDefaultValue = defaultValue; - mAdjuster = adjuster; - - setSnapToTicks(true); - setMajorTickSpacing(6); - setMinorTickSpacing(2); - setPaintTicks(true); - setPaintLabels(true); - - Hashtable labels = new Hashtable<>(); - labels.put(3, new JLabel("3")); - labels.put(9, new JLabel("9")); - labels.put(15, new JLabel("15")); - labels.put(21, new JLabel("21")); - labels.put(27, new JLabel("27")); - - setLabelTable(labels); - - addChangeListener(this); - - addMouseListener(new MouseListener() - { - @Override - public void mouseClicked(MouseEvent event) - { - if(event.getClickCount() == 2) - { - SmoothingItem.this.setValue(mDefaultValue); - } - } - - public void mouseReleased(MouseEvent arg0) - { - } - - public void mousePressed(MouseEvent arg0) - { - } - - public void mouseExited(MouseEvent arg0) - { - } - - public void mouseEntered(MouseEvent arg0) - { - } - }); - } - - @Override - public void stateChanged(ChangeEvent event) - { - int value = ((JSlider)event.getSource()).getValue(); - - if(value % 2 == 1) - { - mAdjuster.setSmoothing(value); - } - } -} diff --git a/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingTypeItem.java b/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingTypeItem.java deleted file mode 100644 index c3f7df99..00000000 --- a/src/main/java/io/github/dsheirer/spectrum/menu/SmoothingTypeItem.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.github.dsheirer.spectrum.menu; - -import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter.SmoothingType; -import io.github.dsheirer.spectrum.SpectralDisplayAdjuster; - -import javax.swing.*; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -public class SmoothingTypeItem extends JCheckBoxMenuItem -{ - private static final long serialVersionUID = 1L; - - private SpectralDisplayAdjuster mAdjuster; - private SmoothingType mSmoothingType; - - public SmoothingTypeItem( SpectralDisplayAdjuster adjuster, SmoothingType type ) - { - super( type.name() ); - - mAdjuster = adjuster; - mSmoothingType = type; - - setSelected( mAdjuster.getSmoothingType() == type ); - - addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) - { - mAdjuster.setSmoothingType( mSmoothingType ); - } - } ); - } -} diff --git a/src/main/resources/spectral_display.css b/src/main/resources/spectral_display.css new file mode 100644 index 00000000..4af76097 --- /dev/null +++ b/src/main/resources/spectral_display.css @@ -0,0 +1,256 @@ +/* + * ***************************************************************************** + * 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 + * **************************************************************************** + */ + +/* Spectral display configuration panel. The panel floats over the (normally black) spectrum, so it uses the same + dark translucent style with both the light and the dark application themes. */ + +.spectral-config-panel +{ + -fx-base: #3a3d41; + -fx-background: #26282b; + -fx-control-inner-background: #303236; + -fx-control-inner-background-alt: #383a3e; + -fx-accent: #4a90d9; + -fx-focus-color: #4a90d9; + -fx-faint-focus-color: #4a90d922; + -fx-text-base-color: #ececec; + -fx-text-background-color: #ececec; + -fx-mark-color: #ececec; + -fx-background-color: rgba(28, 30, 33, 0.84); + -fx-background-radius: 8; + -fx-border-color: rgba(255, 255, 255, 0.16); + -fx-border-radius: 8; + -fx-border-width: 1; + -fx-padding: 4; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.45), 8, 0, 0, 2); +} + +.spectral-config-panel.collapsed +{ + -fx-background-color: rgba(28, 30, 33, 0.55); + -fx-padding: 5; + -fx-cursor: hand; +} + +.spectral-config-panel .label +{ + -fx-text-fill: #dcdcdc; +} + +.spectral-config-panel .spectral-config-title +{ + -fx-font-weight: bold; + -fx-text-fill: #ffffff; +} + +.spectral-config-panel .spectral-config-section +{ + -fx-font-weight: bold; + -fx-text-fill: #8fbaf0; + -fx-padding: 6 0 0 0; +} + +.spectral-config-panel .spectral-config-icon +{ + -fx-fill: #e0e0e0; +} + +.spectral-config-panel .spectral-config-pin +{ + -fx-background-color: transparent; + -fx-padding: 2 5 2 5; +} + +.spectral-config-panel .spectral-config-pin:selected +{ + -fx-background-color: #4a90d9; + -fx-background-radius: 4; +} + +.spectral-config-panel .spectral-config-scroll, +.spectral-config-panel .spectral-config-scroll > .viewport +{ + -fx-background-color: transparent; + -fx-background-insets: 0; + -fx-padding: 0; +} + +.spectral-config-panel .toggle-switch > .thumb-area +{ + -fx-background-color: #45484c; + -fx-border-color: #5a5e62; +} + +.spectral-config-panel .toggle-switch:selected > .thumb-area +{ + -fx-background-color: #4a90d9; + -fx-border-color: #4a90d9; +} + +.spectral-config-panel .toggle-switch > .thumb +{ + -fx-background-color: #ececec; +} + +/* Spectral display split pane between the spectrum and the waterfall */ + +.spectral-display-split-pane +{ + -fx-padding: 0; + -fx-background-color: black; + -fx-background-insets: 0; + -fx-box-border: transparent; +} + +.spectral-display-split-pane > .split-pane-divider +{ + -fx-background-color: #3a3d41; + -fx-padding: 0 0 2 0; +} + +.spectral-config-panel .spectral-config-hint +{ + -fx-text-fill: #a0a8b4; + -fx-font-size: 0.9em; +} + +/* Edge slider: thin strip along the right edge of the display that expands into a vertical slider on mouse over */ + +.spectral-edge-slider +{ + -fx-base: #3a3d41; + -fx-background: #26282b; + -fx-accent: #4a90d9; + -fx-focus-color: #4a90d9; + -fx-faint-focus-color: #4a90d922; + -fx-cursor: hand; +} + +.spectral-edge-slider-strip +{ + -fx-background-color: rgba(200, 205, 212, 0.28); + -fx-background-radius: 2; +} + +.spectral-edge-slider-marker +{ + -fx-background-color: #4a90d9; + -fx-background-radius: 2; + -fx-border-color: rgba(255, 255, 255, 0.7); + -fx-border-radius: 2; + -fx-border-width: 1; +} + +.spectral-edge-slider-panel +{ + -fx-background-color: rgba(28, 30, 33, 0.84); + -fx-background-radius: 8 0 0 8; + -fx-border-color: rgba(255, 255, 255, 0.16); + -fx-border-radius: 8 0 0 8; + -fx-border-width: 1 0 1 1; + -fx-padding: 6 4 6 4; + -fx-cursor: default; +} + +.spectral-edge-slider-title +{ + -fx-font-weight: bold; + -fx-font-size: 0.9em; + -fx-text-fill: #8fbaf0; +} + +.spectral-edge-slider-value +{ + -fx-font-size: 0.85em; + -fx-text-fill: #dcdcdc; +} + +/* Channel symbol view (ScatterChart) colored by the spectral display theme. SymbolView.setTheme() defines the + looked-up colors on the view. Defaults match the Classic theme. */ + +.symbol-view +{ + symbol-background: #000000; + symbol-color: #ffffffcc; + symbol-grid: #c0c0c02d; + symbol-text: #c0c0c0; + -fx-background-color: symbol-background; +} + +.symbol-view .chart, +.symbol-view .chart-content +{ + -fx-background-color: symbol-background; +} + +.symbol-view .chart-plot-background +{ + -fx-background-color: symbol-background; +} + +.symbol-view .chart-vertical-grid-lines, +.symbol-view .chart-horizontal-grid-lines +{ + -fx-stroke: symbol-grid; +} + +.symbol-view .chart-vertical-zero-line, +.symbol-view .chart-horizontal-zero-line +{ + -fx-stroke: symbol-grid; +} + +.symbol-view .chart-symbol +{ + -fx-background-color: symbol-color; + -fx-background-radius: 2px; + -fx-padding: 1px; +} + +.symbol-view .axis +{ + -fx-tick-label-fill: symbol-text; + -fx-tick-mark-stroke: symbol-grid; + AXIS_COLOR: symbol-grid; + -fx-background-color: transparent; +} + +.symbol-view .axis-label, +.symbol-view .chart-title +{ + -fx-text-fill: symbol-text; +} + +.symbol-view .chart-legend +{ + -fx-background-color: transparent; +} + +.symbol-view .chart-legend-item +{ + -fx-text-fill: symbol-text; +} + +/* The dark application theme colors every Text node, which would override the label text fill */ +.symbol-view .chart-legend-item .text, +.symbol-view .axis-label .text, +.symbol-view .chart-title .text +{ + -fx-fill: symbol-text; +} diff --git a/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModelTest.java b/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModelTest.java new file mode 100644 index 00000000..2a3666d7 --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayModelTest.java @@ -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 + * **************************************************************************** + */ + +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); + } +} diff --git a/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayThemeTest.java b/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayThemeTest.java new file mode 100644 index 00000000..ea7c4a82 --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/spectrum/SpectralDisplayThemeTest.java @@ -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 + * **************************************************************************** + */ + +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)); + } +} diff --git a/src/test/java/io/github/dsheirer/gui/spectrum/SpectrumAveragerTest.java b/src/test/java/io/github/dsheirer/gui/spectrum/SpectrumAveragerTest.java new file mode 100644 index 00000000..2528080c --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/spectrum/SpectrumAveragerTest.java @@ -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 + * **************************************************************************** + */ + +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)); + } +} diff --git a/src/test/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayerTest.java b/src/test/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayerTest.java new file mode 100644 index 00000000..1991001b --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/spectrum/layer/DecibelLayerTest.java @@ -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 + * **************************************************************************** + */ + +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)); + } +} diff --git a/src/test/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayerTest.java b/src/test/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayerTest.java new file mode 100644 index 00000000..14315361 --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayerTest.java @@ -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 + * **************************************************************************** + */ + +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)); + } +} diff --git a/src/test/java/io/github/dsheirer/spectrum/FullScaleDecibelTest.java b/src/test/java/io/github/dsheirer/spectrum/FullScaleDecibelTest.java new file mode 100644 index 00000000..64e2593a --- /dev/null +++ b/src/test/java/io/github/dsheirer/spectrum/FullScaleDecibelTest.java @@ -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 + * **************************************************************************** + */ + +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); + } + } +}