mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
* Popout configuration panel (top-left of display) * Themed color support * Reworked signal averating/smoothing * Optional decibel overlay with corrected decibel calculations * Integrated noise floor slider in channel view * Traffic channel overlays * Popup channel information when hovering over playlist channels. * Removes legacy swing components. Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -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<TunerEvent>
|
||||
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<TunerEvent>
|
||||
(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<TunerEvent>
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<ProcessingC
|
||||
private final PlaylistManager mPlaylistManager;
|
||||
private ProcessingChain mProcessingChain;
|
||||
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
||||
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
|
||||
private SpectrumPanel mSpectrumPanel;
|
||||
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
|
||||
private final JFXPanel mSpectralPanel = new JFXPanel();
|
||||
//Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
|
||||
private ChannelSpectralDisplay mChannelSpectralDisplay;
|
||||
private long mChannelFrequency;
|
||||
private int mChannelSampleRate;
|
||||
private int mChannelBandwidth;
|
||||
private Long mCarrierOffset;
|
||||
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
||||
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
|
||||
private final JLabel mTunerPPMLabel;
|
||||
private final JLabel mTunerCorrectionLabel;
|
||||
private final JLabel mChannelCorrectionLabel;
|
||||
private final JLabel mDecoderCorrectionLabel;
|
||||
private final JLabel mDummyLabel = new JLabel(" ");
|
||||
private boolean mPanelVisible = false;
|
||||
private boolean mDftProcessing = false;
|
||||
private final NoiseSquelchView mNoiseSquelchView;
|
||||
private final SignalPowerView mSignalPowerView;
|
||||
private final SymbolView mSymbolView = new SymbolView();
|
||||
@@ -108,7 +99,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
|
||||
public ChannelSpectrumPanel(PlaylistManager playlistManager)
|
||||
{
|
||||
mPlaylistManager = playlistManager;
|
||||
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
||||
@@ -124,7 +115,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
|
||||
|
||||
JPanel labelPanel = new JPanel();
|
||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
|
||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
|
||||
|
||||
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
|
||||
mTunerPPMLabel.setEnabled(false);
|
||||
@@ -146,15 +137,6 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
labelPanel.add(mDummyLabel);
|
||||
|
||||
mNoiseFloorSpinnerModel = new SpinnerNumberModel(18, 8, 36, 1);
|
||||
mNoiseFloorSpinnerModel.addChangeListener(e -> {
|
||||
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<ProcessingC
|
||||
|
||||
fftPanel.add(labelPanel, "wrap");
|
||||
|
||||
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
|
||||
mSpectrumPanel = new SpectrumPanel(settingsManager);
|
||||
mSpectrumPanel.setSampleSize(18.0);
|
||||
fftPanel.add(mSpectralPanel);
|
||||
ThemeManager.getInstance().registerSwing(mSpectralPanel);
|
||||
|
||||
/**
|
||||
* The layered pane holds the overlapping spectrum and channel panels
|
||||
* and manages the sizing of each panel with the resize listener
|
||||
*/
|
||||
JLayeredPane layeredPanel = new JLayeredPane();
|
||||
layeredPanel.addComponentListener(new ResizeListener());
|
||||
|
||||
/**
|
||||
* Create a mouse adapter to handle mouse events over the spectrum
|
||||
* and waterfall panels
|
||||
*/
|
||||
MouseEventProcessor mouser = new MouseEventProcessor();
|
||||
|
||||
mFrequencyOverlayPanel.addMouseListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseMotionListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseWheelListener(mouser);
|
||||
|
||||
//Add the spectrum and channel panels to the layered panel
|
||||
layeredPanel.add(mSpectrumPanel, 0, 0);
|
||||
layeredPanel.add(mFrequencyOverlayPanel, 1, 0);
|
||||
|
||||
fftPanel.add(layeredPanel);
|
||||
Platform.runLater(() -> {
|
||||
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<ProcessingC
|
||||
}
|
||||
|
||||
ThemeManager.getInstance().register(scene2);
|
||||
//Use the same color theme as the channel spectral display
|
||||
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
|
||||
mSymbolPanel.setScene(scene2);
|
||||
});
|
||||
|
||||
@@ -258,11 +226,49 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
add(mSplitPane);
|
||||
// mSplitPane.setDividerLocation(0.5);
|
||||
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
|
||||
mComplexDftProcessor.addConverter(DFTResultsConverter);
|
||||
DFTResultsConverter.addListener(mSpectrumPanel);
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
|
||||
*/
|
||||
private synchronized void displayCreated(ChannelSpectralDisplay display)
|
||||
{
|
||||
mChannelSpectralDisplay = display;
|
||||
|
||||
if(mChannelSampleRate > 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<ProcessingC
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
|
||||
* Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
|
||||
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
|
||||
*/
|
||||
private void updateFFTProcessing()
|
||||
private synchronized void updateFFTProcessing()
|
||||
{
|
||||
if(mPanelVisible && mProcessingChain != null)
|
||||
if(mChannelSpectralDisplay == null)
|
||||
{
|
||||
startDftProcessing();
|
||||
return;
|
||||
}
|
||||
|
||||
boolean active = mPanelVisible && mProcessingChain != null;
|
||||
|
||||
if(active)
|
||||
{
|
||||
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
|
||||
}
|
||||
else
|
||||
{
|
||||
stopDftProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts DFT processing
|
||||
*/
|
||||
private void startDftProcessing()
|
||||
{
|
||||
if(!mDftProcessing)
|
||||
{
|
||||
mDftProcessing = true;
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
mComplexDftProcessor.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops DFT processing
|
||||
*/
|
||||
private void stopDftProcessing()
|
||||
{
|
||||
if(mDftProcessing)
|
||||
{
|
||||
mSampleStreamTapModule.removeListener();
|
||||
mComplexDftProcessor.stop();
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
mDftProcessing = false;
|
||||
}
|
||||
|
||||
mChannelSpectralDisplay.setActive(active);
|
||||
}
|
||||
|
||||
private void broadcast(SourceEvent sourceEvent)
|
||||
@@ -359,10 +347,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mChannelCorrectionLabel.setEnabled(false);
|
||||
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
||||
mDecoderCorrectionLabel.setEnabled(false);
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(0);
|
||||
setChannel(0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,12 +399,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mProcessingChain.addModule(mSampleStreamTapModule);
|
||||
Source source = mProcessingChain.getSource();
|
||||
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
|
||||
}
|
||||
|
||||
int bandwidth = 0;
|
||||
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
||||
|
||||
if(channel != null)
|
||||
@@ -428,9 +408,14 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
if(!tunerChannels.isEmpty())
|
||||
{
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
|
||||
bandwidth = tunerChannels.getFirst().getBandwidth();
|
||||
}
|
||||
}
|
||||
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
|
||||
}
|
||||
}
|
||||
|
||||
updateFFTProcessing();
|
||||
@@ -486,7 +471,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
//We se the offset cursor line to negative so that it aligns with where the signal is located
|
||||
//instead of the correction that's needed to bring the signal to zero.
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
|
||||
setCarrierOffset(-status.getDecoderCorrection());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -495,52 +480,4 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mSignalPowerView.receive(sourceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
mFrequencyOverlayPanel.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
|
||||
{
|
||||
@Override public void mouseMoved(MouseEvent event)
|
||||
{
|
||||
update(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cursor display while the mouse is performing actions
|
||||
*/
|
||||
private void update(MouseEvent event)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorLocation(event.getPoint());
|
||||
}
|
||||
|
||||
@Override public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(true);
|
||||
}
|
||||
|
||||
@Override public void mouseExited(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.gui.spectrum;
|
||||
|
||||
import 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<INativeBuffer> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<SpectralDisplayTheme> 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<DFTSize> mDftSize = new SimpleObjectProperty<>(DFTSize.FFT04096);
|
||||
private final ObjectProperty<WindowType> 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<SpectralDisplayTheme> 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 extends Enum<E>> E parse(Class<E> 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<DFTSize> dftSizeProperty()
|
||||
{
|
||||
return mDftSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT window type.
|
||||
*/
|
||||
public ObjectProperty<WindowType> 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.gui.spectrum;
|
||||
|
||||
/**
|
||||
* Sections of the spectral display.
|
||||
*/
|
||||
public enum DisplaySection
|
||||
{
|
||||
SPECTRUM,
|
||||
WATERFALL;
|
||||
}
|
||||
213
src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java
Normal file
213
src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java
Normal file
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
1188
src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java
Normal file
1188
src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<SpectralDisplayTheme> 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> 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> 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<Integer> 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<ConfigurationRow> 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<DFTSize> 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<Tuner> 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<ConfigurationRow> 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 <T> ComboBox<T> comboBox(T... values)
|
||||
{
|
||||
ComboBox<T> comboBox = new ComboBox<>();
|
||||
comboBox.getItems().addAll(values);
|
||||
comboBox.setMaxWidth(Double.MAX_VALUE);
|
||||
trackPopup(comboBox.showingProperty());
|
||||
return comboBox;
|
||||
}
|
||||
|
||||
private static <T> StringConverter<T> converter(java.util.function.Function<T,String> 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<SpectralDisplayTheme>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<MenuItem> 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<ConfigurationRow> getConfigurationRows()
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources and listeners.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
|
||||
private Consumer<Tuner> 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<Tuner> displayedTunerProperty()
|
||||
{
|
||||
return mDisplayedTuner.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuners that can be shown.
|
||||
*/
|
||||
public List<Tuner> getAvailableTuners()
|
||||
{
|
||||
List<Tuner> 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<Tuner> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<ChannelEvent> 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<Channel> mTrafficChannels = ConcurrentHashMap.newKeySet();
|
||||
private final ObjectProperty<ChannelDisplay> 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<Channel> 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<ChannelDisplay> 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<ConfigurationRow> getConfigurationRows()
|
||||
{
|
||||
ComboBox<ChannelDisplay> 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<Channel> getChannels(SpectralDisplayModel model)
|
||||
{
|
||||
long minimum = model.getMinFrequency();
|
||||
long maximum = model.getMaxFrequency();
|
||||
|
||||
if(mChannelsChanged || minimum != mQueryMinimum || maximum != mQueryMaximum)
|
||||
{
|
||||
try
|
||||
{
|
||||
mChannelsChanged = false;
|
||||
List<Channel> 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<PopoutEntry> 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<PopoutEntry> getPopoutEntries(SpectralDisplayModel model, RenderArea area, double cursorX)
|
||||
{
|
||||
List<PopoutEntry> 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<String> 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<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||
{
|
||||
List<MenuItem> 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<String> details, Color color)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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<ConfigurationRow> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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<Float>
|
||||
{
|
||||
@@ -57,6 +62,14 @@ public class SymbolView extends ChannelView implements Listener<Float>
|
||||
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<Float>
|
||||
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<SpectralDisplayTheme> 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.
|
||||
|
||||
Reference in New Issue
Block a user