Compare commits

...

3 Commits

Author SHA1 Message Date
Denny Sheirer
9641d2ef59 #736 New JavaFX spectral display and waterfall for both tuner and channel spectrums. (#2637)
* Popout configuration panel (top-left of display)
* Themed color support
* Reworked signal averating/smoothing
* Optional decibel overlay with corrected decibel calculations
* Integrated noise floor slider in  channel view
* Traffic channel overlays
* Popup channel information when hovering over playlist channels.
* Removes legacy swing components.

Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 07:30:31 -05:00
Denny Sheirer
73fc85aa76 #1045 P25 encrypted call ignore setting. Cleanup several other errors. (#2636)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 05:14:45 -05:00
Thinline Dynamic Solutions
aad971db9c Add Thinline Radio completed-call upload stream type (#2626)
Same /api/call-upload flow as Rdio Scanner, with its own configuration and
playlist editor. Reuses RdioScannerBroadcaster.

Co-authored-by: Michael Chambers <michaelchambers@Michaels-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-13 04:54:08 -05:00
69 changed files with 7787 additions and 3656 deletions

View File

@@ -25,6 +25,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
@@ -49,6 +50,7 @@ import java.net.SocketAddress;
@JsonSubTypes({
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
@JsonSubTypes.Type(value = ThinlineRadioConfiguration.class, name="ThinlineRadioConfiguration"),
@JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),

View File

@@ -24,6 +24,7 @@ import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfigura
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyFeedConfiguration;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerFeedConfiguration;
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
@@ -68,6 +69,7 @@ public class BroadcastFactory
return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration,
inputAudioFormat, mp3Setting, aliasModel);
case RDIOSCANNER_CALL:
case THINLINE_RADIO:
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
inputAudioFormat, mp3Setting, aliasModel);
case OPENMHZ:
@@ -113,6 +115,8 @@ public class BroadcastFactory
return new BroadcastifyCallConfiguration(format);
case RDIOSCANNER_CALL:
return new RdioScannerConfiguration(format);
case THINLINE_RADIO:
return new ThinlineRadioConfiguration(format);
case BROADCASTIFY:
return new BroadcastifyFeedConfiguration(format);
case OPENMHZ:

View File

@@ -32,6 +32,7 @@ public enum BroadcastServerType
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
THINLINE_RADIO("Thinline Radio", "images/rdioscanner.png"),
OPENMHZ("OpenMHz", "images/openmhz.png"),
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),

View File

@@ -113,7 +113,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
}
else
{
mLog.error("Error connecting to Rdio Scanner server on startup [" + response + "]");
mLog.error("Error connecting to " + getBroadcastConfiguration().getServiceName() +
" server on startup [" + response + "]");
setBroadcastState(BroadcastState.ERROR);
}
@@ -249,7 +250,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
}
catch(IOException e)
{
mLog.error("Rdio Scanner API - audio recording file not found - ignoring upload");
mLog.error(getBroadcastConfiguration().getServiceName() +
" API - audio recording file not found - ignoring upload");
}
if(audioBytes != null)
@@ -286,14 +288,14 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
//We get socket reset exceptions occasionally when the remote server doesn't
//fully read our request and immediately responds.
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
mLog.error("Rdio Scanner API file upload fail [" +
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
fileResponse.statusCode() + "] response [" +
fileResponse.body() + "]");
}
else
{
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
mLog.error("Rdio Scanner API file upload fail [" +
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
fileResponse.statusCode() + "] response [" +
fileResponse.body() + "]");
}
@@ -321,7 +323,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
else
{
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
mLog.error("Rdio Scanner API file upload fail [" +
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
fileResponse.statusCode() + "] response [" +
fileResponse.body() + "]");
}
@@ -334,7 +336,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
else
{
//Register an error for the file not found exception
mLog.error("Rdio Scanner API - upload file not found [" +
mLog.error(getBroadcastConfiguration().getServiceName() + " API - upload file not found [" +
audioRecording.getPath().toString() + "]");
incrementErrorAudioCount();
broadcast(new BroadcastEvent(RdioScannerBroadcaster.this,

View File

@@ -19,6 +19,7 @@
package io.github.dsheirer.audio.broadcast.rdioscanner;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
@@ -125,6 +126,15 @@ public class RdioScannerConfiguration extends BroadcastConfiguration
return BroadcastServerType.RDIOSCANNER_CALL;
}
/**
* Product name written to connection and upload logs.
*/
@JsonIgnore
public String getServiceName()
{
return "Rdio Scanner";
}
@Override
public BroadcastConfiguration copyOf()
{

View File

@@ -0,0 +1,75 @@
/*
* *****************************************************************************
* 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.audio.broadcast.thinline;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
/**
* Call upload configuration for Thinline Radio.
*
* Thinline Radio accepts the same completed-call upload used by Rdio Scanner, including the system id, API key,
* and /api/call-upload path.
*/
public class ThinlineRadioConfiguration extends RdioScannerConfiguration
{
/**
* Constructor for Jackson
*/
public ThinlineRadioConfiguration()
{
this(BroadcastFormat.MP3);
}
/**
* Public constructor.
* @param format to use for audio recording (MP3)
*/
public ThinlineRadioConfiguration(BroadcastFormat format)
{
super(format);
}
@JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
@Override
public BroadcastServerType getBroadcastServerType()
{
return BroadcastServerType.THINLINE_RADIO;
}
@JsonIgnore
@Override
public String getServiceName()
{
return "Thinline Radio";
}
@Override
public BroadcastConfiguration copyOf()
{
ThinlineRadioConfiguration copy = new ThinlineRadioConfiguration();
copy.setSystemID(getSystemID());
return copy;
}
}

View File

@@ -57,7 +57,7 @@ public class NowPlayingPanel extends JPanel
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager);
mDetailTabsVisible = detailTabsVisible;
init();

View File

@@ -153,6 +153,14 @@ public class ChannelProcessingManager implements Listener<ChannelEvent>
return !mProcessingChainsMap.isEmpty();
}
/**
* Channels that currently have a processing chain, including temporary traffic channels.
*/
public List<Channel> getProcessingChannels()
{
return new ArrayList<>(mProcessingChainsMap.keySet());
}
/**
* Returns the current processing chain associated with the channel, or
* null if a processing chain is not currently setup for the channel

View File

@@ -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)
{

View File

@@ -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);
}
}
}

View File

@@ -73,6 +73,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
private EventLogConfigurationEditor mEventLogConfigurationEditor;
private RecordConfigurationEditor mRecordConfigurationEditor;
private ToggleSwitch mIgnoreDataCallsButton;
private ToggleSwitch mIgnoreEncryptedCallsButton;
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
private SegmentedButton mModulationSegmentedButton;
private ToggleButton mC4FMToggleButton;
@@ -157,6 +158,15 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
GridPane.setConstraints(directionLabel, 5, row);
gridPane.getChildren().add(directionLabel);
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 6, row);
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
GridPane.setConstraints(ignoreEncryptedLabel, 7, row);
gridPane.getChildren().add(ignoreEncryptedLabel);
row++;
Label modulationHelpLabel = new Label("C4FM: repeaters and non-simulcast trunked systems. LSM: simulcast trunked systems.");
@@ -343,6 +353,21 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
return mLSMToggleButton;
}
private ToggleSwitch getIgnoreEncryptedCallsButton()
{
if(mIgnoreEncryptedCallsButton == null)
{
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
mIgnoreEncryptedCallsButton.setDisable(true);
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
mIgnoreEncryptedCallsButton.selectedProperty()
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
}
return mIgnoreEncryptedCallsButton;
}
private ToggleSwitch getIgnoreDataCallsButton()
{
if(mIgnoreDataCallsButton == null)
@@ -398,11 +423,13 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
protected void setDecoderConfiguration(DecodeConfiguration config)
{
getIgnoreDataCallsButton().setDisable(config == null);
getIgnoreEncryptedCallsButton().setDisable(config == null);
getTrafficChannelPoolSizeSpinner().setDisable(config == null);
if(config instanceof DecodeConfigP25Phase1 decodeConfig)
{
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
if(decodeConfig.getModulation() == Modulation.C4FM)
{
@@ -427,6 +454,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
else
{
getIgnoreDataCallsButton().setSelected(false);
getIgnoreEncryptedCallsButton().setSelected(false);
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0);
getJmbeAGCButton().setDisable(true);
getJmbeNoiseGainSlider().setDisable(true);
@@ -454,6 +482,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
}
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK);
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());

View File

@@ -71,6 +71,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
private IntegerTextField mSystemTextField;
private IntegerTextField mNacTextField;
private ToggleSwitch mIgnoreDataCallsButton;
private ToggleSwitch mIgnoreEncryptedCallsButton;
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
private ToggleSwitch mJmbeAGCButton;
private Slider mJmbeToneGainSlider;
@@ -142,6 +143,15 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
GridPane.setConstraints(directionLabel, 3, row);
gridPane.getChildren().add(directionLabel);
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 4, row);
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
GridPane.setConstraints(ignoreEncryptedLabel, 5, row);
gridPane.getChildren().add(ignoreEncryptedLabel);
Label wacnLabel = new Label("WACN");
wacnLabel.setLabelFor(getWacnTextField());
GridPane.setHalignment(wacnLabel, HPos.RIGHT);
@@ -300,6 +310,21 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
return mEventLogConfigurationEditor;
}
private ToggleSwitch getIgnoreEncryptedCallsButton()
{
if(mIgnoreEncryptedCallsButton == null)
{
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
mIgnoreEncryptedCallsButton.setDisable(true);
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
mIgnoreEncryptedCallsButton.selectedProperty()
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
}
return mIgnoreEncryptedCallsButton;
}
private ToggleSwitch getIgnoreDataCallsButton()
{
if(mIgnoreDataCallsButton == null)
@@ -417,6 +442,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
getIgnoreDataCallsButton().setDisable(false);
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
getIgnoreEncryptedCallsButton().setDisable(false);
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
getTrafficChannelPoolSizeSpinner().setDisable(false);
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
getJmbeAGCButton().setSelected(decodeConfig.isAGC());
@@ -433,6 +460,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
getSystemTextField().setDisable(true);
getNacTextField().setDisable(true);
getIgnoreDataCallsButton().setDisable(true);
getIgnoreEncryptedCallsButton().setDisable(true);
getIgnoreEncryptedCallsButton().setSelected(false);
getTrafficChannelPoolSizeSpinner().setDisable(true);
getJmbeAGCButton().setDisable(true);
getJmbeNoiseGainSlider().setDisable(true);
@@ -465,6 +494,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
int nac = getNacTextField().get();
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue());

View File

@@ -119,6 +119,22 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
return BroadcastServerType.RDIOSCANNER_CALL;
}
/**
* Label for the server URL field. Subclasses can override this for another product that uses the same API.
*/
protected String getHostLabel()
{
return "RdioScanner URL";
}
/**
* Label for the credential field.
*/
protected String getApiKeyLabel()
{
return "API Key";
}
protected GridPane getEditorPane()
{
if(mEditorPane == null)
@@ -157,7 +173,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
GridPane.setConstraints(getNameTextField(), 1, row);
mEditorPane.getChildren().add(getNameTextField());
Label apiKeyLabel = new Label("API Key");
Label apiKeyLabel = new Label(getApiKeyLabel());
apiKeyLabel.setLabelFor(getApiKeyTextField());
GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
GridPane.setConstraints(apiKeyLabel, 0, ++row);
@@ -175,7 +191,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
GridPane.setConstraints(getSystemIdTextField(), 1, row);
mEditorPane.getChildren().add(getSystemIdTextField());
Label hostLabel = new Label("RdioScanner URL");
Label hostLabel = new Label(getHostLabel());
hostLabel.setLabelFor(getHostTextField());
GridPane.setHalignment(hostLabel, HPos.RIGHT);
GridPane.setConstraints(hostLabel, 0, ++row);

View File

@@ -41,6 +41,8 @@ public class StreamEditorFactory
return new BroadcastifyStreamEditor(playlistManager);
case RDIOSCANNER_CALL:
return new RdioScannerEditor(playlistManager);
case THINLINE_RADIO:
return new ThinlineRadioEditor(playlistManager);
case OPENMHZ:
return new OpenMHzEditor(playlistManager);
case BROADCASTIFY_CALL:

View File

@@ -0,0 +1,52 @@
/*
* *****************************************************************************
* 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.playlist.streaming;
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
import io.github.dsheirer.playlist.PlaylistManager;
/**
* Thinline Radio call upload configuration editor.
*
* The form and upload path match the Rdio Scanner editor.
*/
public class ThinlineRadioEditor extends RdioScannerEditor
{
/**
* Constructs an instance
* @param playlistManager for accessing the broadcast model
*/
public ThinlineRadioEditor(PlaylistManager playlistManager)
{
super(playlistManager);
}
@Override
public BroadcastServerType getBroadcastServerType()
{
return BroadcastServerType.THINLINE_RADIO;
}
@Override
protected String getHostLabel()
{
return "Thinline Radio URL";
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
});
}
}

View File

@@ -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)
{
}

View File

@@ -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;
}

View 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);
}
}
}

View File

@@ -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)
{
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}
}

View File

@@ -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()
{
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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));
});
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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)
{
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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)
{
}
}

View File

@@ -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.

View File

@@ -922,6 +922,7 @@ public class DecoderFactory
DecodeConfigP25Phase1 originalP25P1 = (DecodeConfigP25Phase1)config;
DecodeConfigP25Phase1 copyP25P1 = new DecodeConfigP25Phase1();
copyP25P1.setIgnoreDataCalls(originalP25P1.getIgnoreDataCalls());
copyP25P1.setIgnoreEncryptedCalls(originalP25P1.getIgnoreEncryptedCalls());
copyP25P1.setModulation(originalP25P1.getModulation());
copyP25P1.setTrafficChannelPoolSize(originalP25P1.getTrafficChannelPoolSize());
transferJMBESettings(originalP25P1, copyP25P1);
@@ -929,6 +930,9 @@ public class DecoderFactory
case P25_PHASE2:
DecodeConfigP25Phase2 originalP25P2 = (DecodeConfigP25Phase2)config;
DecodeConfigP25Phase2 copyP25P2 = new DecodeConfigP25Phase2();
copyP25P2.setIgnoreDataCalls(originalP25P2.getIgnoreDataCalls());
copyP25P2.setIgnoreEncryptedCalls(originalP25P2.getIgnoreEncryptedCalls());
copyP25P2.setTrafficChannelPoolSize(originalP25P2.getTrafficChannelPoolSize());
transferJMBESettings(originalP25P2, copyP25P2);
if(originalP25P2.getScrambleParameters() != null)

View File

@@ -28,6 +28,8 @@ import io.github.dsheirer.module.decode.event.ChannelEventTracker;
*/
public class P25ChannelEventTracker extends ChannelEventTracker<P25ChannelGrantEvent>
{
private boolean mEncrypted;
/**
* Constructs an instance
*
@@ -37,4 +39,24 @@ public class P25ChannelEventTracker extends ChannelEventTracker<P25ChannelGrantE
{
super(event);
}
/**
* Indicates if this call is known to be encrypted. Once a call is flagged as encrypted, the flag persists for the
* lifetime of this tracker because many channel grant update messages don't carry service options and those
* updates are processed with empty (ie unencrypted) service options.
* @return true if the call is known to be encrypted.
*/
public boolean isEncrypted()
{
return mEncrypted;
}
/**
* Flags this call as encrypted.
* @param encrypted true if encrypted.
*/
public void setEncrypted(boolean encrypted)
{
mEncrypted = encrypted;
}
}

View File

@@ -124,6 +124,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
private ScrambleParameters mPhase2ScrambleParameters;
private Listener<IMessage> mMessageListener;
private boolean mIgnoreDataCalls;
private boolean mIgnoreEncryptedCalls;
//Used only for data calls
private final DecodeEventDuplicateDetector mDuplicateDetector = new DecodeEventDuplicateDetector();
private final TalkerAliasManager mTalkerAliasManager = new TalkerAliasManager();
@@ -139,6 +140,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase1 phase1)
{
mIgnoreDataCalls = phase1.getIgnoreDataCalls();
mIgnoreEncryptedCalls = phase1.getIgnoreEncryptedCalls();
createPhase1TrafficChannels(phase1.getTrafficChannelPoolSize(), phase1);
DecodeConfigP25Phase2 phase2 = new DecodeConfigP25Phase2();
DecoderFactory.transferJMBESettings(phase1, phase2);
@@ -147,6 +149,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
else if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase2 phase2)
{
mIgnoreDataCalls = phase2.getIgnoreDataCalls();
mIgnoreEncryptedCalls = phase2.getIgnoreEncryptedCalls();
DecodeConfigP25Phase1 phase1 = new DecodeConfigP25Phase1();
DecoderFactory.transferJMBESettings(phase2, phase1);
createPhase1TrafficChannels(phase2.getTrafficChannelPoolSize(), phase1);
@@ -619,7 +622,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
//update the ending timestamp so that the duration value is correctly calculated
trackerTS2.updateDurationTraffic(timestamp);
broadcast(trackerTS1);
broadcast(trackerTS2);
}
finally
{
@@ -1149,6 +1152,30 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
return completed;
}
/**
* Indicates if the service options identify the call as encrypted.
* @param serviceOptions to check, optional null
* @return true if encrypted
*/
private static boolean isEncrypted(ServiceOptions serviceOptions)
{
return serviceOptions != null && serviceOptions.isEncrypted();
}
/**
* Updates the tracker's encrypted flag from the current service options. When the talker changed (ie a new
* tracker was created from a channel grant that carries service options) the tracker takes the current value.
* Otherwise, the encrypted flag is only ever set (never cleared) since many update messages carry empty service
* options, which would otherwise make a known-encrypted call look unencrypted.
*/
private static void updateEncrypted(P25ChannelEventTracker tracker, ServiceOptions serviceOptions)
{
if(isEncrypted(serviceOptions))
{
tracker.setEncrypted(true);
}
}
/**
* Sends a channel start request to the ChannelProcessingManager.
*
@@ -1238,6 +1265,8 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
}
updateEncrypted(tracker, serviceOptions);
//The tracked event can have an empty FROM identifier at start of call ... update here
tracker.addIdentifierIfMissing(from);
@@ -1248,7 +1277,8 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
//Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there
//is a traffic channel allocated. If not, allocate one and update the event description.
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant))
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) &&
!(mIgnoreEncryptedCalls && tracker.isEncrypted()))
{
Channel trafficChannel = mAvailablePhase1TrafficChannelQueue.poll();
@@ -1293,6 +1323,22 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
return;
}
//Don't allocate a traffic channel for an encrypted call, unless a traffic channel is already allocated for the
//frequency in which case the call will be processed by the running traffic channel.
if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency))
{
P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions)
.channelDescriptor(apco25Channel)
.details("IGNORED: ENCRYPTED - PHASE 1 CHANNEL GRANT " + serviceOptions)
.identifiers(ic)
.build();
tracker = new P25ChannelEventTracker(event);
tracker.setEncrypted(true);
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
broadcast(tracker);
return;
}
String details = isDataChannelGrant ? "PHASE 1 DATA CHANNEL GRANT " : "PHASE 1 CHANNEL GRANT " +
(serviceOptions != null ? serviceOptions : "");
@@ -1302,6 +1348,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
.identifiers(ic)
.build();
tracker = new P25ChannelEventTracker(event);
updateEncrypted(tracker, serviceOptions);
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
//Allocate a traffic channel for the downlink frequency if one isn't already allocated
@@ -1367,14 +1414,19 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
broadcast(tracker);
}
updateEncrypted(tracker, serviceOptions);
//update the ending timestamp so that the duration value is correctly calculated
tracker.updateDurationControl(timestamp);
broadcast(tracker);
//Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there
//is a traffic channel allocated. If not, allocate one and update the event description.
//Note: a phase 2 traffic channel carries two timeslots. An encrypted call on this timeslot doesn't cause
//the channel to be allocated, but a clear call on the other timeslot will allocate the channel through
//its own tracker and then both timeslots are processed by the traffic channel.
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) &&
(getCurrentControlFrequency() != frequency))
!(mIgnoreEncryptedCalls && tracker.isEncrypted()) && (getCurrentControlFrequency() != frequency))
{
Channel trafficChannel = mAvailablePhase2TrafficChannelQueue.poll();
@@ -1404,7 +1456,25 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
.build();
tracker = new P25ChannelEventTracker(event);
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
addTracker(tracker, frequency, timeslot);
broadcast(tracker);
return;
}
//Don't allocate a traffic channel for an encrypted call. If the frequency is already allocated (ie there's a
//call on the other timeslot), the running traffic channel will process this timeslot as well.
if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency))
{
P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions)
.channelDescriptor(apco25Channel)
.details("IGNORED: ENCRYPTED - PHASE 2 CHANNEL GRANT " + serviceOptions)
.identifiers(ic)
.timeslot(timeslot)
.build();
tracker = new P25ChannelEventTracker(event);
tracker.setEncrypted(true);
addTracker(tracker, frequency, timeslot);
broadcast(tracker);
return;
}
@@ -1417,6 +1487,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
.build();
tracker = new P25ChannelEventTracker(event);
updateEncrypted(tracker, serviceOptions);
addTracker(tracker, frequency, timeslot);
//Allocate a traffic channel for the downlink frequency if one isn't already allocated

View File

@@ -34,6 +34,7 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE
{
private int mTrafficChannelPoolSize = TRAFFIC_CHANNEL_LIMIT_DEFAULT;
private boolean mIgnoreDataCalls = false;
private boolean mIgnoreEncryptedCalls = false;
public DecodeConfigP25()
{
@@ -50,6 +51,25 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE
mIgnoreDataCalls = ignore;
}
/**
* Indicates if the traffic channel manager should not allocate (ie tune) a traffic channel for a channel grant
* where the service options indicate the call is encrypted.
*/
@JacksonXmlProperty(isAttribute = true, localName = "ignore_encrypted_calls")
public boolean getIgnoreEncryptedCalls()
{
return mIgnoreEncryptedCalls;
}
/**
* Sets the ignore encrypted calls option.
* @param ignore true to not allocate traffic channels for encrypted calls.
*/
public void setIgnoreEncryptedCalls(boolean ignore)
{
mIgnoreEncryptedCalls = ignore;
}
@JacksonXmlProperty(isAttribute = true, localName = "traffic_channel_pool_size")
public int getTrafficChannelPoolSize()

View File

@@ -34,7 +34,7 @@ import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.manager.IDiscoveredTunerStatusListener;
import io.github.dsheirer.source.tuner.manager.TunerManager;
import io.github.dsheirer.source.tuner.manager.TunerStatus;
import io.github.dsheirer.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay;
import io.github.dsheirer.util.SwingUtils;
import io.github.dsheirer.util.ThreadPool;
import java.awt.EventQueue;
@@ -592,7 +592,7 @@ public abstract class TunerEditor<T extends Tuner,C extends TunerConfiguration>
if(tuner != null)
{
SystemProperties.getInstance().set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true);
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true);
ThreadPool.CACHED.submit(() -> mTunerManager.getDiscoveredTunerModel().broadcast(new TunerEvent(tuner,
TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY)));

View File

@@ -21,12 +21,12 @@ package io.github.dsheirer.source.tuner.ui;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.spectrum.SpectrumFrame;
import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.gui.spectrum.SpectralDisplayWindow;
import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay;
import io.github.dsheirer.util.SwingUtils;
import io.github.dsheirer.util.ThreadPool;
import java.util.List;
@@ -43,22 +43,19 @@ public class TunerSpectralDisplayManager implements Listener<TunerEvent>
private SpectralDisplayPanel mSpectralDisplayPanel;
private PlaylistManager mPlaylistManager;
private SettingsManager mSettingsManager;
private DiscoveredTunerModel mDiscoveredTunerModel;
/**
* Constructs an instance
* @param panel to manage
* @param panel to manage, or null when running headless
* @param playlistManager for channel updates
* @param settingsManager for settings
* @param discoveredTunerModel to access tuners
*/
public TunerSpectralDisplayManager(SpectralDisplayPanel panel, PlaylistManager playlistManager,
SettingsManager settingsManager, DiscoveredTunerModel discoveredTunerModel)
DiscoveredTunerModel discoveredTunerModel)
{
mSpectralDisplayPanel = panel;
mPlaylistManager = playlistManager;
mSettingsManager = settingsManager;
mDiscoveredTunerModel = discoveredTunerModel;
}
@@ -68,7 +65,8 @@ public class TunerSpectralDisplayManager implements Listener<TunerEvent>
public Tuner showFirstTuner()
{
//Ensure spectral display is enabled before selecting first tuner
if(SystemProperties.getInstance().get(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true))
if(mSpectralDisplayPanel != null &&
SystemProperties.getInstance().get(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true))
{
List<DiscoveredTuner> availableTuners = mDiscoveredTunerModel.getAvailableTuners();
@@ -88,20 +86,24 @@ public class TunerSpectralDisplayManager implements Listener<TunerEvent>
@Override
public void receive(TunerEvent event)
{
if(mSpectralDisplayPanel == null)
{
return;
}
switch(event.getEvent())
{
case REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY:
SwingUtils.run(() -> mSpectralDisplayPanel.clearTuner());
break;
case REQUEST_MAIN_SPECTRAL_DISPLAY:
if(SystemProperties.getInstance().get(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true))
if(SystemProperties.getInstance().get(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true))
{
SwingUtils.run(() -> mSpectralDisplayPanel.showTuner(event.getTuner()));
}
break;
case REQUEST_NEW_SPECTRAL_DISPLAY:
final SpectrumFrame frame = new SpectrumFrame(mPlaylistManager, mSettingsManager, mDiscoveredTunerModel, event.getTuner());
SwingUtils.run(() -> frame.setVisible(true));
SpectralDisplayWindow.show(mPlaylistManager, mDiscoveredTunerModel, event.getTuner());
break;
case NOTIFICATION_ERROR_STATE:
case NOTIFICATION_SHUTTING_DOWN:

View File

@@ -46,10 +46,12 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
private static final String FRAME_RATE_PROPERTY = "spectral.display.frame.rate";
//The Cosine and Hann windows seem to offer the best spectral display with minimal bin leakage/smearing
private WindowType mWindowType = WindowType.BLACKMAN_HARRIS_7;
private volatile WindowType mWindowType = WindowType.BLACKMAN_HARRIS_7;
private volatile boolean mWindowChanged = true;
private volatile boolean mUnityGainWindow = false;
private float[] mWindow;
private DFTSize mDFTSize = DFTSize.FFT04096;
private DFTSize mNewDFTSize = DFTSize.FFT04096;
private volatile DFTSize mNewDFTSize = DFTSize.FFT04096;
private FloatFFT_1D mFFT = new FloatFFT_1D(mDFTSize.getSize());
private int mFrameRate;
private AtomicBoolean mRunning = new AtomicBoolean();
@@ -62,13 +64,14 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
public ComplexDftProcessor()
{
mFrameRate = SystemProperties.getInstance().get(FRAME_RATE_PROPERTY, 20);
setWindowType(mWindowType);
mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow);
start();
}
public void dispose()
{
stop();
mExecutorService.shutdown();
mListeners.clear();
mWindow = null;
@@ -79,15 +82,87 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
return mWindowType;
}
/**
* Queues a window type change. The processor applies the change before the next calculation.
*/
public void setWindowType(WindowType windowType)
{
mWindowType = windowType;
updateWindow();
if(windowType != null)
{
mWindowType = windowType;
mWindowChanged = true;
}
}
private void updateWindow()
/**
* Scales the window to unity coherent gain, so that a full scale complex tone centered in a bin has a DFT
* magnitude equal to the DFT size, regardless of the window type. Use this with a full scale (dBFS) converter.
* Defaults to false, which leaves the window coefficients unscaled.
*/
public void setUnityGainWindow(boolean unityGain)
{
mWindow = WindowFactory.getWindow(mWindowType, mDFTSize.getSize() * 2);
mUnityGainWindow = unityGain;
mWindowChanged = true;
}
/**
* Creates the window coefficients for interleaved complex samples, with the same coefficient applied to the I
* and Q values of each sample.
* @param type of window. Kaiser windows need extra parameters, so Kaiser uses a Blackman-Harris 7 window.
* @param size of the DFT in complex samples
* @param unityGain to scale the window so the coefficients sum to the DFT size (unity coherent gain)
* @return interleaved window coefficients, twice the DFT size
*/
public static float[] createWindow(WindowType type, int size, boolean unityGain)
{
if(type == null || type == WindowType.KAISER)
{
type = WindowType.BLACKMAN_HARRIS_7;
}
float[] window = WindowFactory.getWindow(type, size);
double sum = 0.0;
for(float coefficient: window)
{
sum += coefficient;
}
float scale = (unityGain && sum > 0) ? (float)(size / sum) : 1.0f;
float[] interleaved = new float[size * 2];
for(int x = 0; x < size; x++)
{
interleaved[2 * x] = window[x] * scale;
interleaved[2 * x + 1] = window[x] * scale;
}
return interleaved;
}
/**
* Equivalent noise bandwidth of the window type, in DFT bins. This is how much wider the window's noise
* bandwidth is than one bin: 1.0 for no window and about 2 to 4 for the low leakage windows.
*/
public static double getNoiseBandwidth(WindowType type)
{
if(type == null || type == WindowType.KAISER)
{
type = WindowType.BLACKMAN_HARRIS_7;
}
int size = 4096;
float[] window = WindowFactory.getWindow(type, size);
double sum = 0.0;
double sumSquares = 0.0;
for(float coefficient: window)
{
sum += coefficient;
sumSquares += coefficient * coefficient;
}
return sum > 0 ? size * sumSquares / (sum * sum) : 1.0;
}
/**
@@ -110,6 +185,15 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
}
public void setFrameRate(int framesPerSecond)
{
setFrameRateNoSave(framesPerSecond);
SystemProperties.getInstance().set(FRAME_RATE_PROPERTY, mFrameRate);
}
/**
* Sets the frame rate without saving it as the default frame rate setting.
*/
public void setFrameRateNoSave(int framesPerSecond)
{
if(framesPerSecond < 1 || framesPerSecond > 1000)
{
@@ -118,7 +202,6 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
}
mFrameRate = framesPerSecond;
SystemProperties.getInstance().set(FRAME_RATE_PROPERTY, mFrameRate);
restart();
}
@@ -176,6 +259,11 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
{
//If this throws an IO exception, the buffer queue is (temporarily) empty and we return from the method
float[] samples = mDftBufferManager.get(mDFTSize.getSize());
if(mWindow == null || mWindow.length != samples.length)
{
mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow);
}
WindowFactory.apply(mWindow, samples);
mFFT.complexForward(samples);
mPreviousSamples = samples;
@@ -237,7 +325,7 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
}
/**
* Checks for a queued FFT width change request and applies it. This
* Checks for a queued FFT width or window change request and applies it. This
* method will only be accessed by the scheduled executor that gains
* access to run a calculate method, thus providing thread safety.
*/
@@ -246,9 +334,15 @@ public class ComplexDftProcessor<T extends INativeBuffer> implements Listener<T>
if(mNewDFTSize.getSize() != mDFTSize.getSize())
{
mDFTSize = mNewDFTSize;
updateWindow();
mWindowChanged = true;
mFFT = new FloatFFT_1D(mDFTSize.getSize());
}
if(mWindowChanged)
{
mWindowChanged = false;
mWindow = createWindow(mWindowType, mDFTSize.getSize(), mUnityGainWindow);
}
}
public void clearBuffer()

View File

@@ -18,7 +18,10 @@
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel;
import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.util.ThreadPool;
import jiconfont.icons.font_awesome.FontAwesome;
import io.github.dsheirer.gui.theme.ThemedFontIcon;
@@ -44,8 +47,8 @@ public class DisableSpectrumWaterfallMenuItem extends JMenuItem
addActionListener(e -> {
SystemProperties properties = SystemProperties.getInstance();
properties.set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, false);
mSpectralDisplayPanel.clearTuner();
properties.set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false);
ThreadPool.CACHED.submit(() -> mSpectralDisplayPanel.clearTuner());
});
}
}

View File

@@ -1,608 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.settings.ColorSetting;
import io.github.dsheirer.settings.ColorSetting.ColorSettingName;
import io.github.dsheirer.settings.Setting;
import io.github.dsheirer.settings.SettingChangeListener;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.ISourceEventProcessor;
import io.github.dsheirer.source.SourceEvent;
import java.awt.Color;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.geom.Line2D;
import java.awt.geom.Rectangle2D;
import java.text.DecimalFormat;
import org.apache.commons.math3.util.FastMath;
import javax.swing.JPanel;
/**
* Frequency overlay panel.
*/
public class FrequencyOverlayPanel extends JPanel implements ISourceEventProcessor, SettingChangeListener
{
private static final long serialVersionUID = 1L;
private final DecimalFormat PPM_FORMATTER = new DecimalFormat( "#.0" );
private final static RenderingHints RENDERING_HINTS = new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
static
{
RENDERING_HINTS.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
}
private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("000.00000");
private long mFrequency = 0;
private int mBandwidth = 0;
private int mChannelBandwidth = 0;
private long mEstimatedCarrierOffsetFrequency = 0;
private Point mCursorLocation = new Point(0, 0);
private boolean mCursorVisible = false;
private DFTSize mDFTSize = DFTSize.FFT04096;
/**
* Colors used by this component
*/
private Color mColorSpectrumBackground;
private Color mColorSpectrumCursor;
private Color mColorSpectrumLine;
//Defines the offset at the bottom of the spectral display to account for
//the frequency labels
private double mSpectrumInset = 20.0d;
private LabelSizeManager mLabelSizeMonitor = new LabelSizeManager();
private SettingsManager mSettingsManager;
/**
* Translucent overlay panel for displaying channel configurations,
* processing channels, selected channels, frequency labels and lines, and
* a cursor with a frequency readout.
*/
public FrequencyOverlayPanel(SettingsManager settingsManager)
{
mSettingsManager = settingsManager;
if(mSettingsManager != null)
{
mSettingsManager.addListener(this);
}
addComponentListener(mLabelSizeMonitor);
//Set the background transparent, so the spectrum display can be seen
setOpaque(false);
//Fetch color settings from settings manager
setColors();
}
/**
* Sets the estimated carrier offset of the channel's signal
* @param estimatedCarrierOffsetFrequency current setting. Set to 0 to reset.
*/
public void setEstimatedCarrierOffsetFrequency(long estimatedCarrierOffsetFrequency)
{
mEstimatedCarrierOffsetFrequency = estimatedCarrierOffsetFrequency;
}
/**
* Sets the bandwidth of the channel.
* @param channelBandwidth in Hertz
*/
public void setChannelBandwidth(int channelBandwidth)
{
mChannelBandwidth = channelBandwidth;
}
public void dispose()
{
if(mSettingsManager != null)
{
mSettingsManager.removeListener(this);
}
mSettingsManager = null;
}
/**
* Sets/changes the DFT bin size
*/
public void setDFTSize(DFTSize size)
{
mDFTSize = size;
}
public void setCursorLocation(Point point)
{
mCursorLocation = point;
repaint();
}
public void setCursorVisible(boolean visible)
{
mCursorVisible = visible;
repaint();
}
/**
* Fetches the color settings from the settings manager
*/
private void setColors()
{
mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR);
mColorSpectrumLine = getColor(ColorSettingName.SPECTRUM_LINE);
mColorSpectrumBackground = getColor(ColorSettingName.SPECTRUM_BACKGROUND);
}
/**
* Fetches a named color setting from the settings manager. If the setting
* doesn't exist, creates the setting using the defaultColor
*/
private Color getColor(ColorSettingName name)
{
ColorSetting setting = mSettingsManager.getColorSetting(name);
return setting.getColor();
}
/**
* Monitors for setting changes. Colors can be changed by external actions
* and will automatically update in this class
*/
@Override
public void settingChanged(Setting setting)
{
if(setting instanceof ColorSetting)
{
ColorSetting colorSetting = (ColorSetting)setting;
switch(colorSetting.getColorSettingName())
{
case SPECTRUM_BACKGROUND:
mColorSpectrumBackground = colorSetting.getColor();
break;
case SPECTRUM_CURSOR:
mColorSpectrumCursor = colorSetting.getColor();
break;
case SPECTRUM_LINE:
mColorSpectrumLine = colorSetting.getColor();
break;
default:
break;
}
}
}
/**
* Renders the channel configs, lines, labels, and cursor
*/
@Override
public void paintComponent(Graphics g)
{
super.paintComponent(g);
Graphics2D graphics = (Graphics2D)g;
graphics.setBackground(mColorSpectrumBackground);
graphics.setRenderingHints(RENDERING_HINTS);
drawFrequencies(graphics);
drawCursor(graphics);
drawEstimatedCarrierOffset(graphics);
drawChannelBandwidth(graphics);
}
/**
* Draws a cursor on the panel, whenever the mouse is hovering over the
* panel
*/
private void drawCursor(Graphics2D graphics)
{
if(mCursorVisible)
{
drawFrequencyLine(graphics, mCursorLocation.x, mColorSpectrumCursor);
String frequency = CURSOR_FORMAT.format(getFrequencyFromAxis(mCursorLocation.getX()) / 1E6D);
FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont());
Rectangle2D rect = fontMetrics.getStringBounds(frequency, graphics);
if(mCursorLocation.y > rect.getHeight())
{
graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y);
}
}
}
/**
* Draws the frequency lines and labels every 10kHz
*/
private void drawFrequencies(Graphics2D graphics)
{
long minFrequency = getMinDisplayFrequency();
long maxFrequency = getMaxDisplayFrequency();
//Frequency increments for label and tick spacing
int label = mLabelSizeMonitor.getLabelIncrement(graphics);
int major = mLabelSizeMonitor.getMajorTickIncrement(graphics);
int minor = mLabelSizeMonitor.getMinorTickIncrement(graphics);
//Avoid divide by zero error
if(minor == 0)
{
minor = 1;
}
if(label == 0)
{
label = 1;
}
//Adjust the start frequency to a multiple of the minor tick spacing
long frequency = minFrequency - (minFrequency % minor);
while(frequency < maxFrequency)
{
if(frequency % label == 0)
{
drawFrequencyLineAndLabel(graphics, frequency);
}
else if(frequency % major == 0)
{
drawTickLine(graphics, frequency, true);
}
else
{
drawTickLine(graphics, frequency, false);
}
frequency += minor;
}
}
/**
* Draws a vertical line and a corresponding frequency label at the bottom
*/
private void drawFrequencyLineAndLabel(Graphics2D graphics, long frequency)
{
double xAxis = getAxisFromFrequency(frequency);
drawFrequencyLine(graphics, xAxis, mColorSpectrumLine);
drawTickLine(graphics, frequency, false);
graphics.setColor(mColorSpectrumLine);
drawFrequencyLabel(graphics, xAxis, frequency);
}
/**
* Draws a vertical line at the xaxis
*/
private void drawTickLine(Graphics2D graphics, long frequency, boolean major)
{
graphics.setColor(mColorSpectrumLine);
double xAxis = getAxisFromFrequency(frequency);
double start = getSize().getHeight() - mSpectrumInset;
double end = start + (major ? 9.0d : 3.0d);
graphics.draw(new Line2D.Double(xAxis, start, xAxis, end));
}
/**
* Draws a vertical line at the xaxis
*/
private void drawFrequencyLine(Graphics2D graphics, double xaxis, Color color)
{
graphics.setColor(color);
graphics.draw(new Line2D.Double(xaxis, 0.0d, xaxis, getSize().getHeight() - mSpectrumInset));
}
/**
* Draws a vertical line at the xaxis
*/
private void drawChannelCenterLine(Graphics2D graphics, double xaxis)
{
double height = getSize().getHeight() - mSpectrumInset;
graphics.setColor(Color.LIGHT_GRAY);
graphics.draw(new Line2D.Double(xaxis, height * 0.65d, xaxis, height - 1.0d));
}
/**
* Draws the estimated carrier offset measured by the CarrierOffsetProcessor
*/
private void drawEstimatedCarrierOffset(Graphics2D graphics)
{
long frequency = mFrequency + mEstimatedCarrierOffsetFrequency;
double xAxis = getAxisFromFrequency(frequency);
double height = getSize().getHeight() - mSpectrumInset;
double verticalAxisTop = height * 0.02d;
double verticalAxisBottom = height * 0.98d;
graphics.setColor(Color.YELLOW);
//Vertical band edge lines
graphics.draw(new Line2D.Double(xAxis, verticalAxisTop, xAxis, verticalAxisBottom));
}
/**
* Draws the channel bandwidth indicators
*/
private void drawChannelBandwidth(Graphics2D graphics)
{
if(mChannelBandwidth == 0)
{
return;
}
long minFrequency = mFrequency - (mChannelBandwidth / 2);
long maxFrequency = mFrequency + (mChannelBandwidth / 2);
double height = getSize().getHeight() - mSpectrumInset;
double verticalAxisTop = height * 0.8d;
double verticalAxisBottom = height * 0.98d;
double minXAxis = getAxisFromFrequency(minFrequency);
double maxXAxis = getAxisFromFrequency(maxFrequency);
graphics.setColor(Color.GREEN);
graphics.draw(new Line2D.Double(minXAxis, verticalAxisTop, minXAxis, verticalAxisBottom));
graphics.draw(new Line2D.Double(maxXAxis, verticalAxisTop, maxXAxis, verticalAxisBottom));
}
/**
* Returns the x-axis value corresponding to the frequency
*/
private double getAxisFromFrequency(long frequency)
{
double screenWidth = (double)getSize().getWidth();
double pixelsPerBin = screenWidth / (double)mDFTSize.getSize();
double pixelOffsetToMinDisplayFrequency = pixelsPerBin * 2.0d;
//Calculate frequency offset from the min frequency
double frequencyOffset = (double)(frequency - getMinDisplayFrequency());
//Determine ratio of frequency offset to overall bandwidth
double ratio = frequencyOffset / (double)getDisplayBandwidth();
//Apply the ratio to the screen width minus 1 bin width
double screenOffset = screenWidth * ratio;
return pixelOffsetToMinDisplayFrequency + screenOffset;
}
/**
* Returns the frequency corresponding to the x-axis value using the current
* zoom level.
*/
public long getFrequencyFromAxis(double xAxis)
{
double width = getSize().getWidth();
double offset = xAxis / width;
long frequency = getMinDisplayFrequency() + FastMath.round((double)getDisplayBandwidth() * offset);
if(frequency > (getMaxFrequency()))
{
frequency = getMaxFrequency();
}
return frequency;
}
/**
* Draws a frequency label at the x-axis position, at the bottom of the panel
*/
private void drawFrequencyLabel(Graphics2D graphics, double xaxis, long frequency)
{
String label = mLabelSizeMonitor.format(frequency);
FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont());
Rectangle2D rect = fontMetrics.getStringBounds(label, graphics);
float xOffset = (float)rect.getWidth() / 2;
graphics.drawString(label, (float)(xaxis - xOffset), (float)(getSize().getHeight() - 2.0f));
}
/**
* Frequency change event handler
*/
@Override
public void process(SourceEvent event)
{
switch(event.getEvent())
{
case NOTIFICATION_SAMPLE_RATE_CHANGE:
mBandwidth = event.getValue().intValue();
mLabelSizeMonitor.update();
break;
case NOTIFICATION_FREQUENCY_CHANGE:
mFrequency = event.getValue().longValue();
mLabelSizeMonitor.update();
break;
default:
break;
}
}
public int getBandwidth()
{
return mBandwidth;
}
/**
* Currently displayed minimum frequency
*/
public long getMinFrequency()
{
return mFrequency - (mBandwidth / 2);
}
/**
* Currently displayed maximum frequency
*/
public long getMaxFrequency()
{
return mFrequency + (mBandwidth / 2);
}
public boolean containsFrequency(long frequency)
{
return FastMath.abs(mFrequency - frequency) <= (mBandwidth / 2);
}
private long getMinDisplayFrequency()
{
double bandwidthPerBin = (double)mBandwidth / (double)(mDFTSize.getSize());
return getMinFrequency();
// return getMinFrequency() + (int)((mDFTZoomWindowOffset) * bandwidthPerBin);
}
private long getMaxDisplayFrequency()
{
return getMinDisplayFrequency() + getDisplayBandwidth();
}
private int getDisplayBandwidth()
{
return mBandwidth;
}
@Override
public void settingDeleted(Setting setting) { /* not implemented */ }
/**
* Calculates correct spacing and format for frequency labels and major/minor
* tick lines based on current frequency, bandwidth, zoom and screen size.
*/
public class LabelSizeManager implements ComponentListener
{
private static final double LABEL_FILL_THRESHOLD = 0.5d;
private DecimalFormat mFrequencyFormat = new DecimalFormat("0.0");
private boolean mUpdateRequired = true;
private int mLabelIncrement = 1;
private int mMajorTickIncrement = 1;
private int mMinorTickIncrement = 1;
public String format(long frequency)
{
return mFrequencyFormat.format((double)frequency / 1E6D);
}
private void setPrecision(int precision)
{
if(precision < 1)
{
precision = 1;
}
if(precision > 5)
{
precision = 5;
}
mFrequencyFormat.setMinimumFractionDigits(precision);
mFrequencyFormat.setMaximumFractionDigits(precision);
}
private void update(Graphics2D graphics)
{
if(mUpdateRequired)
{
//Set maximum precision as a starting point
setPrecision(5);
FontMetrics fontMetrics =
graphics.getFontMetrics(FrequencyOverlayPanel.this.getFont());
int maxLabelWidth = fontMetrics.stringWidth(format(getMaxDisplayFrequency()));
double maxLabels = ((double) FrequencyOverlayPanel.this.getWidth() * LABEL_FILL_THRESHOLD) / (double)maxLabelWidth;
//Calculate the next smallest base 10 value for the major increment
int power = (int)FastMath.log10((double)getDisplayBandwidth() / maxLabels);
//Set the number of decimal places to display in frequency labels
int precision = 5 - power;
int start = (int)FastMath.pow(10.0, power + 1);
int minimum = (int)FastMath.pow(10.0, power);
int labelIncrement = start;
while(((double)getDisplayBandwidth() / (double)labelIncrement) < maxLabels && labelIncrement >= minimum)
{
labelIncrement /= 2;
precision++;
}
if(labelIncrement == minimum)
{
precision = 5 - power;
}
setPrecision(precision);
mLabelIncrement = labelIncrement;
mMajorTickIncrement = labelIncrement / 2;
mMinorTickIncrement = labelIncrement / 10;
mUpdateRequired = false;
}
}
/**
* Forces the display to update the label and frequency display
* calculations
*/
public void update()
{
mUpdateRequired = true;
}
public int getMajorTickIncrement(Graphics2D graphics)
{
//Check to see if a calculation update is scheduled
update(graphics);
return mMajorTickIncrement;
}
public int getMinorTickIncrement(Graphics2D graphics)
{
return mMinorTickIncrement;
}
public int getLabelIncrement(Graphics2D graphics)
{
return mLabelIncrement;
}
@Override
public void componentResized(ComponentEvent arg0)
{
update();
}
public void componentHidden(ComponentEvent arg0) {}
public void componentMoved(ComponentEvent arg0) {}
public void componentShown(ComponentEvent arg0) {}
}
}

View File

@@ -1,948 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.ColorSetting;
import io.github.dsheirer.settings.ColorSetting.ColorSettingName;
import io.github.dsheirer.settings.Setting;
import io.github.dsheirer.settings.SettingChangeListener;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.ISourceEventProcessor;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.Stroke;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.geom.Line2D;
import java.awt.geom.Rectangle2D;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.commons.math3.util.FastMath;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JPanel;
public class OverlayPanel extends JPanel implements Listener<ChannelEvent>, ISourceEventProcessor, SettingChangeListener
{
private static final long serialVersionUID = 1L;
private final static Logger mLog = LoggerFactory.getLogger(OverlayPanel.class);
private final DecimalFormat PPM_FORMATTER = new DecimalFormat( "#.0" );
private final static RenderingHints RENDERING_HINTS = new RenderingHints(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
static
{
RENDERING_HINTS.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
}
private final static BasicStroke DASHED_STROKE = new BasicStroke(0.8f, BasicStroke.CAP_BUTT,
BasicStroke.JOIN_MITER, 5.0f, new float[]{2.0f, 4.0f}, 0.0f);
private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("000.00000");
private long mFrequency = 0;
private int mBandwidth = 0;
private Point mCursorLocation = new Point(0, 0);
private boolean mCursorVisible = false;
private DFTSize mDFTSize = DFTSize.FFT04096;
private int mZoom = 0;
private int mDFTZoomWindowOffset = 0;
/**
* Colors used by this component
*/
private Color mColorChannelConfig;
private Color mColorChannelConfigProcessing;
private Color mColorChannelConfigSelected;
private Color mColorSpectrumBackground;
private Color mColorSpectrumCursor;
private Color mColorSpectrumLine;
//Currently visible/displayable channels
private List<Channel> mVisibleChannels = new CopyOnWriteArrayList<>();
private List<Channel> mTrafficChannels = new CopyOnWriteArrayList<>();
private ChannelDisplay mChannelDisplay = ChannelDisplay.ALL;
//Defines the offset at the bottom of the spectral display to account for
//the frequency labels
private double mSpectrumInset = 20.0d;
private LabelSizeManager mLabelSizeMonitor = new LabelSizeManager();
private SettingsManager mSettingsManager;
private ChannelModel mChannelModel;
private ChannelProcessingManager mChannelProcessingManager;
/**
* Translucent overlay panel for displaying channel configurations,
* processing channels, selected channels, frequency labels and lines, and
* a cursor with a frequency readout.
*/
public OverlayPanel(SettingsManager settingsManager, ChannelModel channelModel, ChannelProcessingManager channelProcessingManager)
{
mSettingsManager = settingsManager;
if(mSettingsManager != null)
{
mSettingsManager.addListener(this);
}
mChannelModel = channelModel;
if(mChannelModel != null)
{
mChannelModel.addListener(this::receive);
}
mChannelProcessingManager = channelProcessingManager;
if(mChannelProcessingManager != null)
{
mChannelProcessingManager.addChannelEventListener(this::receive);
}
addComponentListener(mLabelSizeMonitor);
//Set the background transparent, so the spectrum display can be seen
setOpaque(false);
//Fetch color settings from settings manager
setColors();
}
public void dispose()
{
if(mChannelModel != null)
{
mChannelModel.removeListener(this);
}
mChannelModel = null;
mVisibleChannels.clear();
if(mSettingsManager != null)
{
mSettingsManager.removeListener(this);
}
mSettingsManager = null;
}
/**
* Sets/changes the DFT bin size
*/
public void setDFTSize(DFTSize size)
{
mDFTSize = size;
}
public ChannelDisplay getChannelDisplay()
{
return mChannelDisplay;
}
public void setChannelDisplay(ChannelDisplay display)
{
mChannelDisplay = display;
}
public void setCursorLocation(Point point)
{
mCursorLocation = point;
repaint();
}
public void setCursorVisible(boolean visible)
{
mCursorVisible = visible;
repaint();
}
/**
* Sets the current zoom level (2^zoom)
*
* 0 No Zoom
* 1 2x Zoom
* 2 4x Zoom
* 3 8x Zoom
* 4 16x Zoom
* 5 32x Zoom
* 6 64x Zoom
*
* @param zoom level, 0 - 6.
*/
public void setZoom(int zoom)
{
mZoom = zoom;
mLabelSizeMonitor.update();
}
public void setZoomWindowOffset(int offset)
{
mDFTZoomWindowOffset = offset;
}
/**
* Fetches the color settings from the settings manager
*/
private void setColors()
{
mColorChannelConfig = getColor(ColorSettingName.CHANNEL_CONFIG);
mColorChannelConfigProcessing = getColor(ColorSettingName.CHANNEL_CONFIG_PROCESSING);
mColorChannelConfigSelected = getColor(ColorSettingName.CHANNEL_CONFIG_SELECTED);
mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR);
mColorSpectrumLine = getColor(ColorSettingName.SPECTRUM_LINE);
mColorSpectrumBackground = getColor(ColorSettingName.SPECTRUM_BACKGROUND);
}
/**
* Fetches a named color setting from the settings manager. If the setting
* doesn't exist, creates the setting using the defaultColor
*/
private Color getColor(ColorSettingName name)
{
ColorSetting setting = mSettingsManager.getColorSetting(name);
return setting.getColor();
}
/**
* Monitors for setting changes. Colors can be changed by external actions
* and will automatically update in this class
*/
@Override
public void settingChanged(Setting setting)
{
if(setting instanceof ColorSetting)
{
ColorSetting colorSetting = (ColorSetting)setting;
switch(colorSetting.getColorSettingName())
{
case CHANNEL_CONFIG:
mColorChannelConfig = colorSetting.getColor();
break;
case CHANNEL_CONFIG_PROCESSING:
mColorChannelConfigProcessing = colorSetting.getColor();
break;
case CHANNEL_CONFIG_SELECTED:
mColorChannelConfigSelected = colorSetting.getColor();
break;
case SPECTRUM_BACKGROUND:
mColorSpectrumBackground = colorSetting.getColor();
break;
case SPECTRUM_CURSOR:
mColorSpectrumCursor = colorSetting.getColor();
break;
case SPECTRUM_LINE:
mColorSpectrumLine = colorSetting.getColor();
break;
default:
break;
}
}
}
/**
* Renders the channel configs, lines, labels, and cursor
*/
@Override
public void paintComponent(Graphics g)
{
super.paintComponent(g);
Graphics2D graphics = (Graphics2D)g;
graphics.setBackground(mColorSpectrumBackground);
graphics.setRenderingHints(RENDERING_HINTS);
drawFrequencies(graphics);
drawChannels(graphics);
drawCursor(graphics);
}
/**
* Draws a cursor on the panel, whenever the mouse is hovering over the
* panel
*/
private void drawCursor(Graphics2D graphics)
{
if(mCursorVisible)
{
drawFrequencyLine(graphics, mCursorLocation.x, mColorSpectrumCursor);
String frequency = CURSOR_FORMAT.format(getFrequencyFromAxis(mCursorLocation.getX()) / 1E6D);
FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont());
Rectangle2D rect = fontMetrics.getStringBounds(frequency, graphics);
if(mCursorLocation.y > rect.getHeight())
{
graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y);
}
if(mZoom != 0)
{
graphics.drawString("Zoom: " + (int)FastMath.pow(2.0, mZoom) + "x", mCursorLocation.x + 17,
mCursorLocation.y + 11);
}
}
}
/**
* Draws the frequency lines and labels every 10kHz
*/
private void drawFrequencies(Graphics2D graphics)
{
Stroke currentStroke = graphics.getStroke();
long minFrequency = getMinDisplayFrequency();
long maxFrequency = getMaxDisplayFrequency();
//Frequency increments for label and tick spacing
int label = mLabelSizeMonitor.getLabelIncrement(graphics);
int major = mLabelSizeMonitor.getMajorTickIncrement(graphics);
int minor = mLabelSizeMonitor.getMinorTickIncrement(graphics);
//Avoid divide by zero error
if(minor == 0)
{
minor = 1;
}
if(label == 0)
{
label = 1;
}
//Adjust the start frequency to a multiple of the minor tick spacing
long frequency = minFrequency - (minFrequency % minor);
while(frequency < maxFrequency)
{
if(frequency % label == 0)
{
drawFrequencyLineAndLabel(graphics, frequency);
}
else if(frequency % major == 0)
{
drawTickLine(graphics, frequency, true);
}
else
{
drawTickLine(graphics, frequency, false);
}
frequency += minor;
}
}
/**
* Draws a vertical line and a corresponding frequency label at the bottom
*/
private void drawFrequencyLineAndLabel(Graphics2D graphics, long frequency)
{
double xAxis = getAxisFromFrequency(frequency);
drawFrequencyLine(graphics, xAxis, mColorSpectrumLine);
drawTickLine(graphics, frequency, false);
graphics.setColor(mColorSpectrumLine);
drawFrequencyLabel(graphics, xAxis, frequency);
}
/**
* Draws a vertical line at the xaxis
*/
private void drawTickLine(Graphics2D graphics, long frequency, boolean major)
{
graphics.setColor(mColorSpectrumLine);
double xAxis = getAxisFromFrequency(frequency);
double start = getSize().getHeight() - mSpectrumInset;
double end = start + (major ? 9.0d : 3.0d);
graphics.draw(new Line2D.Double(xAxis, start, xAxis, end));
}
/**
* Draws a vertical line at the xaxis
*/
private void drawFrequencyLine(Graphics2D graphics, double xaxis, Color color)
{
graphics.setColor(color);
graphics.draw(new Line2D.Double(xaxis, 0.0d, xaxis, getSize().getHeight() - mSpectrumInset));
}
/**
* Draws a vertical line at the xaxis
*/
private void drawChannelCenterLine(Graphics2D graphics, double xaxis)
{
double height = getSize().getHeight() - mSpectrumInset;
graphics.setColor(Color.LIGHT_GRAY);
graphics.draw(new Line2D.Double(xaxis, height * 0.65d, xaxis, height - 1.0d));
}
/**
* Draws the Automatic Frequency Control (AFC) channel center offset
*/
private void drawAFC(Graphics2D graphics, double frequencyAxis, double errorAxis, double bandwidth,
int correction, long frequency)
{
double height = getSize().getHeight() - mSpectrumInset;
double verticalAxisTop = height * 0.88d;
double verticalAxisBottom = height * 0.98d;
double halfBandwidth = bandwidth / 2.0;
double errorEdgeStart = errorAxis - halfBandwidth;
double errorEdgeStop = errorAxis + halfBandwidth;
graphics.setColor(Color.YELLOW);
//Horizontal line connecting frequency and error line
graphics.draw(new Line2D.Double(errorEdgeStart, verticalAxisBottom, errorEdgeStop, verticalAxisBottom));
//Vertical band edge lines
graphics.draw(new Line2D.Double(errorEdgeStart, verticalAxisTop, errorEdgeStart, verticalAxisBottom));
graphics.draw(new Line2D.Double(errorEdgeStop, verticalAxisTop, errorEdgeStop, verticalAxisBottom));
double ppm = (double)correction / ((double)frequency / 1E6d);
String label = "PPM " + PPM_FORMATTER.format(ppm) ;
FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont());
Rectangle2D rect = fontMetrics.getStringBounds(label, graphics);
//Only render the correction value label if the spacing is large enough
if(rect.getWidth() <= bandwidth && rect.getHeight() * 5 <= height)
{
graphics.drawString(label, (float)(errorEdgeStart + 1.0), (float)(verticalAxisBottom - 2.0));
}
}
/**
* Returns the x-axis value corresponding to the frequency
*/
private double getAxisFromFrequency(long frequency)
{
double screenWidth = (double)getSize().getWidth();
double pixelsPerBin = screenWidth / (double)mDFTSize.getSize();
double pixelOffsetToMinDisplayFrequency = pixelsPerBin * 2.0d;
//Calculate frequency offset from the min frequency
double frequencyOffset = (double)(frequency - getMinDisplayFrequency());
//Determine ratio of frequency offset to overall bandwidth
double ratio = frequencyOffset / (double)getDisplayBandwidth();
//Apply the ratio to the screen width minus 1 bin width
double screenOffset = screenWidth * ratio;
return pixelOffsetToMinDisplayFrequency + screenOffset;
}
/**
* Returns the frequency corresponding to the x-axis value using the current
* zoom level.
*/
public long getFrequencyFromAxis(double xAxis)
{
double width = getSize().getWidth();
double offset = xAxis / width;
long frequency = getMinDisplayFrequency() + FastMath.round((double)getDisplayBandwidth() * offset);
if(frequency > (getMaxFrequency()))
{
frequency = getMaxFrequency();
}
return frequency;
}
/**
* Draws a frequency label at the x-axis position, at the bottom of the panel
*/
private void drawFrequencyLabel(Graphics2D graphics, double xaxis, long frequency)
{
String label = mLabelSizeMonitor.format(frequency);
FontMetrics fontMetrics = graphics.getFontMetrics(this.getFont());
Rectangle2D rect = fontMetrics.getStringBounds(label, graphics);
float xOffset = (float)rect.getWidth() / 2;
graphics.drawString(label, (float)(xaxis - xOffset), (float)(getSize().getHeight() - 2.0f));
}
/**
* Draws visible channel configs as translucent shaded frequency regions
*/
private void drawChannels(Graphics2D graphics)
{
for(Channel channel : mVisibleChannels)
{
if(mChannelDisplay == ChannelDisplay.ALL || (mChannelDisplay == ChannelDisplay.ENABLED && channel.isProcessing()))
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
for(TunerChannel tunerChannel: tunerChannels)
{
if(tunerChannel.overlaps(getMinDisplayFrequency(), getMaxDisplayFrequency()))
{
//Choose the correct background color to use
if(channel.isSelected())
{
graphics.setColor(mColorChannelConfigSelected);
}
else if(channel.isProcessing())
{
graphics.setColor(mColorChannelConfigProcessing);
}
else
{
graphics.setColor(mColorChannelConfig);
}
double xAxis = getAxisFromFrequency(tunerChannel.getFrequency());
double width = (double)(tunerChannel.getBandwidth()) / (double)getDisplayBandwidth() * getSize().getWidth();
Rectangle2D.Double box = new Rectangle2D.Double(xAxis - (width / 2.0d), 0.0d, width,
getSize().getHeight() - mSpectrumInset);
//Fill the box with the correct color
graphics.fill(box);
graphics.draw(box);
//Change to the line color to render the channel name, etc.
graphics.setColor(mColorSpectrumLine);
//Draw the labels starting at yAxis position 0
double yAxis = 0;
//Draw the system label and adjust the y-axis position
String system = channel.hasSystem() ? channel.getSystem() : " ";
yAxis += drawLabel(graphics, system, this.getFont(), xAxis, yAxis, width);
//Draw the site label and adjust the y-axis position
String site = channel.hasSite() ? channel.getSite() : " ";
yAxis += drawLabel(graphics, site, this.getFont(), xAxis, yAxis, width);
//Draw the channel label and adjust the y-axis position
yAxis += drawLabel(graphics, channel.getName(), this.getFont(), xAxis, yAxis, width);
//Draw the decoder label
drawLabel(graphics, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(),
this.getFont(), xAxis, yAxis, width);
long frequency = tunerChannel.getFrequency();
double frequencyAxis = getAxisFromFrequency(frequency);
drawChannelCenterLine(graphics, frequencyAxis);
/* Draw Automatic Frequency Control line */
int correction = channel.getChannelFrequencyCorrection();
if(correction != 0)
{
long error = frequency + correction;
drawAFC(graphics, frequencyAxis, getAxisFromFrequency(error), width, correction,
tunerChannel.getFrequency());
}
}
}
}
}
}
/**
* Draws a textual label at the x/y position, clipping the end of the text
* to fit within the maxwidth value.
*
* @return height of the drawn label
*/
private double drawLabel(Graphics2D graphics, String text, Font font, double x, double baseY, double maxWidth)
{
FontMetrics fontMetrics = graphics.getFontMetrics(font);
if(text == null || text.isEmpty())
{
return 0;
}
Rectangle2D label = fontMetrics.getStringBounds(text, graphics);
double offset = label.getWidth() / 2.0d;
double y = baseY + label.getHeight();
/**
* If the label is wider than the max width, left justify the text and
* clip the end of it
*/
if(offset > (maxWidth / 2.0d))
{
label.setRect(x - (maxWidth / 2.0d), y - label.getHeight(), maxWidth, label.getHeight());
graphics.setClip(label);
graphics.drawString(text, (float)(x - (maxWidth / 2.0d)), (float)y);
graphics.setClip(null);
}
else
{
graphics.drawString(text, (float)(x - offset), (float)y);
}
return label.getHeight();
}
/**
* Frequency change event handler
*/
@Override
public void process(SourceEvent event)
{
switch(event.getEvent())
{
case NOTIFICATION_SAMPLE_RATE_CHANGE:
mBandwidth = event.getValue().intValue();
mLabelSizeMonitor.update();
break;
case NOTIFICATION_FREQUENCY_CHANGE:
mFrequency = event.getValue().longValue();
mLabelSizeMonitor.update();
break;
default:
break;
}
/**
* Reset the visible channels list
*/
mVisibleChannels.clear();
mVisibleChannels.addAll(mChannelModel.getChannelsInFrequencyRange(getMinFrequency(), getMaxFrequency()));
for(Channel trafficChannel: mTrafficChannels)
{
if(trafficChannel.isWithin(getMinFrequency(), getMaxFrequency()))
{
mVisibleChannels.add(trafficChannel);
}
}
}
/**
* Channel change event handler
*/
@Override
public void receive(ChannelEvent event)
{
Channel channel = event.getChannel();
switch(event.getEvent())
{
case NOTIFICATION_ADD:
case NOTIFICATION_PROCESSING_START:
if(channel.getChannelType() == ChannelType.TRAFFIC && !mTrafficChannels.contains(channel))
{
mTrafficChannels.add(channel);
}
if(!mVisibleChannels.contains(channel) && channel.isWithin(getMinFrequency(), getMaxFrequency()))
{
mVisibleChannels.add(channel);
}
break;
case NOTIFICATION_DELETE:
mVisibleChannels.remove(channel);
break;
case NOTIFICATION_PROCESSING_STOP:
case NOTIFICATION_PROCESSING_START_REJECTED:
if(channel.getChannelType() == ChannelType.TRAFFIC)
{
mVisibleChannels.remove(channel);
mTrafficChannels.remove(channel);
}
break;
case NOTIFICATION_CONFIGURATION_CHANGE:
if(mVisibleChannels.contains(channel) && !channel.isWithin(getMinFrequency(), getMaxFrequency()))
{
mVisibleChannels.remove(channel);
}
if(!mVisibleChannels.contains(channel) && channel.isWithin(getMinFrequency(), getMaxFrequency()))
{
mVisibleChannels.add(channel);
}
break;
default:
break;
}
repaint();
}
public int getBandwidth()
{
return mBandwidth;
}
/**
* Currently displayed minimum frequency
*/
public long getMinFrequency()
{
return mFrequency - (mBandwidth / 2);
}
/**
* Currently displayed maximum frequency
*/
public long getMaxFrequency()
{
return mFrequency + (mBandwidth / 2);
}
public boolean containsFrequency(long frequency)
{
return FastMath.abs(mFrequency - frequency) <= (mBandwidth / 2);
}
private long getMinDisplayFrequency()
{
double bandwidthPerBin = (double)mBandwidth / (double)(mDFTSize.getSize());
return getMinFrequency() + (int)((mDFTZoomWindowOffset) * bandwidthPerBin);
}
private long getMaxDisplayFrequency()
{
return getMinDisplayFrequency() + getDisplayBandwidth();
}
private int getDisplayBandwidth()
{
if(mZoom != 0)
{
return mBandwidth / (int)FastMath.pow(2.0, mZoom);
}
return mBandwidth;
}
/**
* Returns a list of channel configs that contain the frequency within their
* min/max frequency settings.
*/
public ArrayList<Channel> getChannelsAtFrequency(long frequency)
{
ArrayList<Channel> configs = new ArrayList<Channel>();
for(Channel config : mVisibleChannels)
{
List<TunerChannel> channels = config.getTunerChannels();
for(TunerChannel channel: channels)
{
if(channel != null && channel.getMinFrequency() <= frequency && channel.getMaxFrequency() >= frequency)
{
configs.add(config);
}
}
}
return configs;
}
@Override
public void settingDeleted(Setting setting)
{ /* not implemented */ }
/**
* Calculates correct spacing and format for frequency labels and major/minor
* tick lines based on current frequency, bandwidth, zoom and screen size.
*/
public class LabelSizeManager implements ComponentListener
{
private static final double LABEL_FILL_THRESHOLD = 0.5d;
private DecimalFormat mFrequencyFormat = new DecimalFormat("0.0");
private boolean mUpdateRequired = true;
private int mLabelIncrement = 1;
private int mMajorTickIncrement = 1;
private int mMinorTickIncrement = 1;
public String format(long frequency)
{
return mFrequencyFormat.format((double)frequency / 1E6D);
}
private void setPrecision(int precision)
{
if(precision < 1)
{
precision = 1;
}
if(precision > 5)
{
precision = 5;
}
mFrequencyFormat.setMinimumFractionDigits(precision);
mFrequencyFormat.setMaximumFractionDigits(precision);
}
private void update(Graphics2D graphics)
{
if(mUpdateRequired)
{
//Set maximum precision as a starting point
setPrecision(5);
FontMetrics fontMetrics =
graphics.getFontMetrics(OverlayPanel.this.getFont());
int maxLabelWidth = fontMetrics.stringWidth(format(getMaxDisplayFrequency()));
double maxLabels = ((double)OverlayPanel.this.getWidth() * LABEL_FILL_THRESHOLD) / (double)maxLabelWidth;
//Calculate the next smallest base 10 value for the major increment
int power = (int)FastMath.log10((double)getDisplayBandwidth() / maxLabels);
//Set the number of decimal places to display in frequency labels
int precision = 5 - power;
int start = (int)FastMath.pow(10.0, power + 1);
int minimum = (int)FastMath.pow(10.0, power);
int labelIncrement = start;
while(((double)getDisplayBandwidth() / (double)labelIncrement) < maxLabels && labelIncrement >= minimum)
{
labelIncrement /= 2;
precision++;
}
if(labelIncrement == minimum)
{
precision = 5 - power;
}
setPrecision(precision);
mLabelIncrement = labelIncrement;
mMajorTickIncrement = labelIncrement / 2;
mMinorTickIncrement = labelIncrement / 10;
mUpdateRequired = false;
}
}
/**
* Forces the display to update the label and frequency display
* calculations
*/
public void update()
{
mUpdateRequired = true;
}
public int getMajorTickIncrement(Graphics2D graphics)
{
//Check to see if a calculation update is scheduled
update(graphics);
return mMajorTickIncrement;
}
public int getMinorTickIncrement(Graphics2D graphics)
{
return mMinorTickIncrement;
}
public int getLabelIncrement(Graphics2D graphics)
{
return mLabelIncrement;
}
@Override
public void componentResized(ComponentEvent arg0)
{
update();
}
public void componentHidden(ComponentEvent arg0)
{
}
public void componentMoved(ComponentEvent arg0)
{
}
public void componentShown(ComponentEvent arg0)
{
}
}
public enum ChannelDisplay
{
ALL, ENABLED, NONE;
}
}

View File

@@ -1,29 +0,0 @@
/*******************************************************************************
* SDR Trunk
* Copyright (C) 2014 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
******************************************************************************/
package io.github.dsheirer.spectrum;
/**
* Interface to add pause action to moving displays like the spectrum and
* waterfall panels
*/
public interface Pausable
{
public void setPaused( boolean paused );
public boolean isPaused();
}

View File

@@ -18,6 +18,7 @@
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.gui.spectrum.TunerSpectralDisplay;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
@@ -50,7 +51,7 @@ public class ShowTunerMenuItem extends JMenuItem
public void run()
{
SystemProperties properties = SystemProperties.getInstance();
properties.set(SpectralDisplayPanel.SPECTRAL_DISPLAY_ENABLED, true);
properties.set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true);
mDiscoveredTunerModel.broadcast(new TunerEvent(mTuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY));
}
});

View File

@@ -1,904 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import com.jidesoft.swing.JideSplitPane;
import io.github.dsheirer.buffer.INativeBuffer;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter.SmoothingType;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.ColorSetting.ColorSettingName;
import io.github.dsheirer.settings.ColorSettingMenuItem;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.ISourceEventProcessor;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import io.github.dsheirer.spectrum.OverlayPanel.ChannelDisplay;
import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter;
import io.github.dsheirer.spectrum.converter.DFTResultsConverter;
import io.github.dsheirer.spectrum.menu.AveragingItem;
import io.github.dsheirer.spectrum.menu.DFTSizeItem;
import io.github.dsheirer.spectrum.menu.FFTWindowTypeItem;
import io.github.dsheirer.spectrum.menu.FrameRateItem;
import io.github.dsheirer.spectrum.menu.SmoothingItem;
import io.github.dsheirer.spectrum.menu.SmoothingTypeItem;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.util.ArrayList;
import java.util.Hashtable;
import net.miginfocom.swing.MigLayout;
import org.apache.commons.math3.util.FastMath;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.JSlider;
import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.MouseInputAdapter;
public class SpectralDisplayPanel extends JPanel
implements Listener<INativeBuffer>, ISourceEventProcessor, IDFTWidthChangeProcessor
{
private static final long serialVersionUID = 1L;
private final static Logger mLog = LoggerFactory.getLogger(SpectralDisplayPanel.class);
public static final String FFT_SIZE_PROPERTY = "spectral.display.dft.size";
public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled";
public static final int NO_ZOOM = 0;
public static final int MAX_ZOOM = 6;
private DFTSize mDFTSize = DFTSize.FFT04096;
private int mZoom = 0;
private int mDFTZoomWindowOffset = 0;
private JScrollPane mScrollPane;
private JLayeredPane mLayeredPanel;
private SpectrumPanel mSpectrumPanel;
private WaterfallPanel mWaterfallPanel;
private OverlayPanel mOverlayPanel;
private ComplexDftProcessor mComplexDftProcessor;
private DFTResultsConverter mDFTConverter;
private ChannelModel mChannelModel;
private ChannelProcessingManager mChannelProcessingManager;
private SettingsManager mSettingsManager;
private DiscoveredTunerModel mDiscoveredTunerModel;
private Tuner mTuner;
/**
* Spectral Display Panel provides a frequency component display with a
* historical waterfall display and a transparent overlay to show frequency,
* cursor and channel information.
* <p>
* Mouse scrolling and zooming are supported and the waterfall display can
* be paused.
* <p>
* Complex sample buffers are processed by a DFTProcessor and the output of
* the DFT is translated to decibels for display in the spectrum and
* waterfall components.
*/
public SpectralDisplayPanel(PlaylistManager playlistManager, SettingsManager settingsManager, DiscoveredTunerModel discoveredTunerModel)
{
mChannelModel = playlistManager.getChannelModel();
mChannelProcessingManager = playlistManager.getChannelProcessingManager();
mSettingsManager = settingsManager;
mDiscoveredTunerModel = discoveredTunerModel;
mSpectrumPanel = new SpectrumPanel(mSettingsManager);
mOverlayPanel = new OverlayPanel(mSettingsManager, mChannelModel, mChannelProcessingManager);
mWaterfallPanel = new WaterfallPanel(mSettingsManager);
init();
loadSettings();
}
private void loadSettings()
{
SystemProperties properties = SystemProperties.getInstance();
String rawSize = properties.get(FFT_SIZE_PROPERTY, DFTSize.FFT04096.name());
DFTSize size = null;
if(rawSize != null)
{
try
{
size = DFTSize.valueOf(rawSize);
}
catch(Exception e)
{
//Do nothing
}
}
if(size == null)
{
size = DFTSize.FFT04096;
}
setDFTSize(size, false);
}
public void dispose()
{
/* De-register from receiving samples when the window closes */
clearTuner();
mSettingsManager = null;
mComplexDftProcessor.dispose();
mComplexDftProcessor = null;
mDFTConverter.dispose();
mDFTConverter = null;
mSpectrumPanel.dispose();
mSpectrumPanel = null;
mWaterfallPanel.dispose();
mWaterfallPanel = null;
mOverlayPanel.dispose();
mOverlayPanel = null;
mTuner = null;
}
/**
* Queues an FFT size change request. The scheduled executor will apply
* the change when it runs.
*/
public void setDFTSize(DFTSize size, boolean save)
{
mComplexDftProcessor.setDFTSize(size);
mOverlayPanel.setDFTSize(size);
mDFTSize = size;
if(save)
{
SystemProperties.getInstance().set(FFT_SIZE_PROPERTY, size.name());
}
setZoom(0, 0, 0);
}
public void setDFTSize(DFTSize size)
{
setDFTSize(size, true);
}
@Override public DFTSize getDFTSize()
{
return mDFTSize;
}
public int getZoom()
{
return mZoom;
}
/**
* Sets the current zoom level which will be 2 to the power of zoom (2^zoom)
* <p>
* 0 No Zoom
* 1 2x Zoom
* 2 4x Zoom
* 3 8x Zoom
* 4 16x Zoom
* 5 32x Zoom
* 6 64x Zoom
*
* @param zoom level, 0 - 6.
* @param frequency under the mouse to maintain while zooming
* @param windowOffset where to maintain the frequency under the mouse
*/
public void setZoom(int zoom, long frequency, double windowOffset)
{
if(zoom < NO_ZOOM)
{
zoom = NO_ZOOM;
}
else if(zoom > MAX_ZOOM)
{
zoom = MAX_ZOOM;
}
if(zoom != mZoom)
{
mZoom = zoom;
//Calculate the bin offset that would place the reference frequency
//at the left edge of the zoom window.
double binOffsetToFrequency = getBinOffset(frequency);
//Calculate the bin offset into the newly sized zoom window that
//would place the frequency in the same proportional window location
//that it was in the previous zoom size
double windowBinOffset = (double)getZoomWindowSizeInBins() * windowOffset;
//Set the overall offset to place the reference frequency in the
//same location in the newly zoomed window
double offset = binOffsetToFrequency - windowBinOffset;
mSpectrumPanel.setZoom(mZoom);
mOverlayPanel.setZoom(mZoom);
mWaterfallPanel.setZoom(mZoom);
setZoomWindowOffset(offset);
}
}
/**
* Sets the offset (in DFT bins) to the first bin that the zoom window displays
*/
public void setZoomWindowOffset(double offset)
{
if(offset < 0)
{
offset = 0;
}
if(offset > (mDFTSize.getSize() - getZoomWindowSizeInBins()))
{
offset = mDFTSize.getSize() - getZoomWindowSizeInBins();
}
mDFTZoomWindowOffset = (int)offset;
mSpectrumPanel.setZoomWindowOffset(mDFTZoomWindowOffset);
mOverlayPanel.setZoomWindowOffset(mDFTZoomWindowOffset);
mWaterfallPanel.setZoomWindowOffset(mDFTZoomWindowOffset);
}
/**
* Calculates the size of the current zoom window in DFT bins
*/
private int getZoomWindowSizeInBins()
{
return mDFTSize.getSize() / getZoomMultiplier();
}
public int getZoomMultiplier()
{
return (int)FastMath.pow(2.0, mZoom);
}
/**
* Calculates the overall offset of the frequency from the current minimum
* frequency in terms of total FFT width
*
* @param frequency
* @return
*/
private double getBinOffset(long frequency)
{
double offset = 0.0;
if(mOverlayPanel.containsFrequency(frequency))
{
offset = (double)mDFTSize.getSize() * ((double)(frequency - mOverlayPanel.getMinFrequency())
/ (double)mOverlayPanel.getBandwidth());
}
return offset;
}
/**
* Overrides JComponent method to return false, since we have overlapping
* panels with the spectrum and channel panels
*/
public boolean isOptimizedDrawingEnabled()
{
return false;
}
private void init()
{
setLayout(new MigLayout("insets 0 0 0 0", "[grow]", "[grow]"));
/**
* The layered pane holds the overlapping spectrum and channel panels
* and manages the sizing of each panel with the resize listener
*/
mLayeredPanel = new JLayeredPane();
mLayeredPanel.addComponentListener(new ResizeListener());
/**
* Create a mouse adapter to handle mouse events over the spectrum
* and waterfall panels
*/
MouseEventProcessor mouser = new MouseEventProcessor();
mOverlayPanel.addMouseListener(mouser);
mOverlayPanel.addMouseMotionListener(mouser);
mOverlayPanel.addMouseWheelListener(mouser);
//Add the spectrum and channel panels to the layered panel
mLayeredPanel.add(mSpectrumPanel, 0, 0);
mLayeredPanel.add(mOverlayPanel, 1, 0);
//Create the waterfall
mWaterfallPanel.addMouseListener(mouser);
mWaterfallPanel.addMouseMotionListener(mouser);
mWaterfallPanel.addMouseWheelListener(mouser);
/* Attempt to set a 50/50 split preferred size for the split pane */
double totalHeight =
mLayeredPanel.getPreferredSize().getHeight() + mWaterfallPanel.getPreferredSize().getHeight();
mLayeredPanel.setPreferredSize(
new Dimension((int)mLayeredPanel.getPreferredSize().getWidth(), (int)(totalHeight / 2.0d)));
mWaterfallPanel.setPreferredSize(
new Dimension((int)mWaterfallPanel.getPreferredSize().getWidth(), (int)(totalHeight / 2.0d)));
//Create the split pane to hold the layered pane and the waterfall
JideSplitPane splitPane = new JideSplitPane(JSplitPane.VERTICAL_SPLIT);
splitPane.setDividerSize(5);
splitPane.add(mLayeredPanel);
splitPane.add(mWaterfallPanel);
mScrollPane = new JScrollPane(splitPane);
add(mScrollPane, "grow");
/**
* Setup DFTProcessor to process samples and register the waterfall and
* spectrum panel to receive the processed dft results
*/
mComplexDftProcessor = new ComplexDftProcessor();
mDFTConverter = new ComplexDecibelConverter();
mComplexDftProcessor.addConverter(mDFTConverter);
mDFTConverter.addListener((DFTResultsListener)mSpectrumPanel);
mDFTConverter.addListener((DFTResultsListener)mWaterfallPanel);
}
/**
* Receives frequency change events -- primarily from tuner components.
*/
public void process(SourceEvent event)
{
mOverlayPanel.process(event);
}
/**
* Complex sample buffer receive method
*/
@Override public void receive(INativeBuffer nativeBuffer)
{
mComplexDftProcessor.receive(nativeBuffer);
}
/**
* Responds to tuner event by deregistering from the current
* complex sample buffer source and registering with the tuner argument.
*/
public void showTuner(Tuner tuner)
{
clearTuner();
mComplexDftProcessor.clearBuffer();
mComplexDftProcessor.start();
mTuner = tuner;
if(mTuner != null)
{
//Register the dft processor to receive samples from the tuner
mTuner.getTunerController().addBufferListener(mComplexDftProcessor);
//Verify that the tuner is still non-null, in case it encountered an error on starting sample stream
if(mTuner != null)
{
//Register to receive frequency change events
mTuner.getTunerController().addListener(this);
mSpectrumPanel.setSampleSize(mTuner.getSampleSize());
//Fire frequency and sample rate change events so that the spectrum
//and overlay panels can synchronize
process(SourceEvent.frequencyChange(null, mTuner.getTunerController().getFrequency()));
process(SourceEvent.sampleRateChange(mTuner.getTunerController().getSampleRate()));
}
}
}
/**
* Tuner de-selection cleanup method
*/
public void clearTuner()
{
if(mTuner != null)
{
//Deregister for frequency change events from the tuner
mTuner.getTunerController().removeListener(SpectralDisplayPanel.this);
//Deregister the dft processor from receiving samples
mTuner.getTunerController().removeBufferListener(mComplexDftProcessor);
mTuner = null;
}
mComplexDftProcessor.stop();
mComplexDftProcessor.clearBuffer();
mSpectrumPanel.clearSpectrum();
mWaterfallPanel.clearWaterfall();
}
/**
* Currently displayed tuner
*/
public Tuner getTuner()
{
return mTuner;
}
/**
* Monitors the sizing of the layered pane and resizes the spectrum and
* channel panels whenever the layered pane is resized
*/
public class ResizeListener implements ComponentListener
{
@Override public void componentResized(ComponentEvent e)
{
Component c = e.getComponent();
mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
mOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
}
@Override public void componentHidden(ComponentEvent arg0)
{
}
@Override public void componentMoved(ComponentEvent arg0)
{
}
@Override public void componentShown(ComponentEvent arg0)
{
}
}
/**
* Mouse event handler for the spectral display panel.
*/
public class MouseEventProcessor extends MouseInputAdapter
{
private int mDFTZoomWindowOffsetAtDragStart = 0;
private int mDragStartX = 0;
private double mPixelsPerBin;
public MouseEventProcessor()
{
}
@Override public void mouseWheelMoved(MouseWheelEvent e)
{
int zoom = mZoom - e.getWheelRotation();
long frequency = mOverlayPanel.getFrequencyFromAxis(e.getX());
double windowOffset = (double)e.getX() / (double)getWidth();
setZoom(zoom, frequency, windowOffset);
}
@Override public void mouseMoved(MouseEvent event)
{
update(event);
}
@Override public void mouseDragged(MouseEvent event)
{
update(event);
int dragDistance = mDragStartX - event.getX();
double binDistance = (double)dragDistance / mPixelsPerBin;
int offset = (int)(mDFTZoomWindowOffsetAtDragStart + binDistance);
if(offset < 0)
{
offset = 0;
}
int maxOffset = mDFTSize.getSize() - (mDFTSize.getSize() / getZoomMultiplier());
if(offset > maxOffset)
{
offset = maxOffset;
}
setZoomWindowOffset(offset);
}
@Override public void mousePressed(MouseEvent e)
{
mDragStartX = e.getX();
mDFTZoomWindowOffsetAtDragStart = mDFTZoomWindowOffset;
mPixelsPerBin = (double)getWidth() / ((double)(mDFTSize.getSize()) / (double)getZoomMultiplier());
}
/**
* Updates the cursor display while the mouse is performing actions
*/
private void update(MouseEvent event)
{
if(event.getComponent() == mOverlayPanel)
{
mOverlayPanel.setCursorLocation(event.getPoint());
}
else
{
mWaterfallPanel.setCursorLocation(event.getPoint());
mWaterfallPanel.setCursorFrequency(mOverlayPanel.getFrequencyFromAxis(event.getPoint().x));
}
}
@Override public void mouseEntered(MouseEvent e)
{
if(e.getComponent() == mOverlayPanel)
{
mOverlayPanel.setCursorVisible(true);
}
else
{
mWaterfallPanel.setCursorVisible(true);
}
}
@Override public void mouseExited(MouseEvent e)
{
mOverlayPanel.setCursorVisible(false);
mWaterfallPanel.setCursorVisible(false);
}
/**
* Displays the context menu.
*/
@Override public void mouseClicked(MouseEvent event)
{
if(SwingUtilities.isRightMouseButton(event))
{
JPopupMenu contextMenu = new JPopupMenu();
if(event.getComponent() == mWaterfallPanel)
{
contextMenu.add(new PauseItem(mWaterfallPanel, "Pause"));
contextMenu.add(new JSeparator());
}
long frequency = mOverlayPanel.getFrequencyFromAxis(event.getX());
if(event.getComponent() == mOverlayPanel)
{
ArrayList<Channel> channels = mOverlayPanel.getChannelsAtFrequency(frequency);
JMenu channelMenu = new JMenu("Channels");
for(Channel channel : channels)
{
JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle());
viewChannel.addActionListener(
e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
channelMenu.add(viewChannel);
}
contextMenu.add(channelMenu);
if(!channels.isEmpty())
{
contextMenu.add(new JSeparator());
}
}
/**
* Color Menus
*/
JMenu colorMenu = new JMenu("Color");
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG_PROCESSING));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.CHANNEL_CONFIG_SELECTED));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_CURSOR));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_LINE));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_BACKGROUND));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_GRADIENT_BOTTOM));
colorMenu.add(new ColorSettingMenuItem(mSettingsManager, ColorSettingName.SPECTRUM_GRADIENT_TOP));
contextMenu.add(colorMenu);
/**
* Display items: fft and frame rate
*/
JMenu displayMenu = new JMenu("Display");
contextMenu.add(displayMenu);
if(event.getComponent() != mWaterfallPanel)
{
/**
* Averaging menu
*/
JMenu averagingMenu = new JMenu("Averaging");
averagingMenu.add(new AveragingItem(mSpectrumPanel, 4));
displayMenu.add(averagingMenu);
/**
* Channel Display setting menu
*/
JMenu channelDisplayMenu = new JMenu("Channel");
channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.ALL));
channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.ENABLED));
channelDisplayMenu.add(new ChannelDisplayItem(mOverlayPanel, ChannelDisplay.NONE));
displayMenu.add(channelDisplayMenu);
}
/**
* FFT width
*/
JMenu fftWidthMenu = new JMenu("FFT Width");
displayMenu.add(fftWidthMenu);
for(DFTSize width : DFTSize.values())
{
fftWidthMenu.add(new DFTSizeItem(SpectralDisplayPanel.this, width));
}
/**
* DFT Processor Frame Rate
*/
JMenu frameRateMenu = new JMenu("Frame Rate");
displayMenu.add(frameRateMenu);
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 14));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 16));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 18));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 20));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 25));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 30));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 40));
frameRateMenu.add(new FrameRateItem(mComplexDftProcessor, 50));
/**
* FFT Window Type
*/
JMenu fftWindowType = new JMenu("Window Type");
displayMenu.add(fftWindowType);
for(WindowType type : WindowType.values())
{
fftWindowType.add(new FFTWindowTypeItem(mComplexDftProcessor, type));
}
if(event.getComponent() != mWaterfallPanel)
{
/**
* Smoothing menu
*/
JMenu smoothingMenu = new JMenu("Smoothing");
if(mSpectrumPanel.getSmoothingType() != SmoothingType.NONE)
{
smoothingMenu.add(new SmoothingItem(mSpectrumPanel, 5));
smoothingMenu.add(new JSeparator());
}
smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.GAUSSIAN));
smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.TRIANGLE));
smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.RECTANGLE));
smoothingMenu.add(new SmoothingTypeItem(mSpectrumPanel, SmoothingType.NONE));
displayMenu.add(smoothingMenu);
}
/*
* Zoom menu
*/
JMenuItem zoomMenu = new JMenu("Zoom");
double windowOffset = (double)event.getX() / (double)getWidth();
zoomMenu.add(new ZoomItem(frequency, windowOffset));
contextMenu.add(zoomMenu);
if(mTuner != null)
{
contextMenu.add(new JSeparator());
contextMenu.add(new DisableSpectrumWaterfallMenuItem(SpectralDisplayPanel.this));
}
boolean separatorAdded = false;
for(DiscoveredTuner discoveredTuner : mDiscoveredTunerModel.getAvailableTuners())
{
if(mTuner == null || mTuner != discoveredTuner.getTuner())
{
if(!separatorAdded)
{
contextMenu.add(new JSeparator());
separatorAdded = true;
}
contextMenu.add(new ShowTunerMenuItem(mDiscoveredTunerModel, discoveredTuner.getTuner()));
}
}
if(contextMenu != null)
{
if(event.getComponent() == mOverlayPanel)
{
contextMenu.show(mOverlayPanel, event.getX(), event.getY());
}
else
{
contextMenu.show(mWaterfallPanel, event.getX(), event.getY());
}
}
}
}
}
public class PauseItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private Pausable mPausable;
public PauseItem(Pausable pausable, String label)
{
super(label);
final boolean paused = pausable.isPaused();
setSelected(paused);
mPausable = pausable;
addActionListener(new ActionListener()
{
@Override public void actionPerformed(ActionEvent e)
{
EventQueue.invokeLater(new Runnable()
{
@Override public void run()
{
mPausable.setPaused(!paused);
}
});
}
});
}
}
public class ZoomItem extends JSlider
{
private static final long serialVersionUID = 1L;
private long mFrequency;
private double mWindowOffset;
public ZoomItem(long frequency, double windowOffset)
{
super(NO_ZOOM, MAX_ZOOM, mZoom);
mFrequency = frequency;
mWindowOffset = windowOffset;
Hashtable<Integer, JComponent> labels = new Hashtable<>();
labels.put(0, new JLabel("1x"));
labels.put(1, new JLabel("2x"));
labels.put(2, new JLabel("4x"));
labels.put(3, new JLabel("8x"));
labels.put(4, new JLabel("16x"));
labels.put(5, new JLabel("32x"));
labels.put(6, new JLabel("64x"));
setLabelTable(labels);
setMajorTickSpacing(1);
setMinorTickSpacing(1);
setPaintTicks(true);
setPaintLabels(true);
this.addChangeListener(new ChangeListener()
{
@Override public void stateChanged(ChangeEvent e)
{
setZoom(getValue(), mFrequency, mWindowOffset);
}
});
}
}
public class ChannelDisplayItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private OverlayPanel mOverlayPanel;
private ChannelDisplay mChannelDisplay;
public ChannelDisplayItem(OverlayPanel panel, ChannelDisplay display)
{
super(display.name());
mOverlayPanel = panel;
mChannelDisplay = display;
setSelected(mOverlayPanel.getChannelDisplay() == mChannelDisplay);
addActionListener(new ActionListener()
{
@Override public void actionPerformed(ActionEvent e)
{
EventQueue.invokeLater(new Runnable()
{
@Override public void run()
{
mOverlayPanel.setChannelDisplay(mChannelDisplay);
}
});
}
});
}
}
}

View File

@@ -1,99 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import net.miginfocom.swing.MigLayout;
import javax.swing.JFrame;
import java.awt.EventQueue;
import java.awt.event.WindowEvent;
import java.awt.event.WindowListener;
public class SpectrumFrame extends JFrame implements WindowListener
{
private static final long serialVersionUID = 1L;
private SpectralDisplayPanel mSpectralDisplayPanel;
public SpectrumFrame(PlaylistManager playlistManager, SettingsManager settingsManager,
DiscoveredTunerModel discoveredTunerModel, Tuner tuner)
{
setTitle("SDRTRunk [" + tuner.getPreferredName() + "]");
setBounds(100, 100, 1280, 600);
setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
setLayout(new MigLayout("insets 0 0 0 0", "[grow]", "[grow]"));
mSpectralDisplayPanel = new SpectralDisplayPanel(playlistManager, settingsManager, discoveredTunerModel);
mSpectralDisplayPanel.showTuner(tuner);
add(mSpectralDisplayPanel, "grow");
/* Register a shutdown listener */
this.addWindowListener(this);
EventQueue.invokeLater(new Runnable()
{
public void run()
{
setVisible(true);
}
});
}
@Override
public void windowClosed(WindowEvent arg0)
{
mSpectralDisplayPanel.dispose();
}
@Override
public void windowActivated(WindowEvent arg0)
{
}
@Override
public void windowClosing(WindowEvent arg0)
{
}
@Override
public void windowDeactivated(WindowEvent arg0)
{
}
@Override
public void windowDeiconified(WindowEvent arg0)
{
}
@Override
public void windowIconified(WindowEvent arg0)
{
}
@Override
public void windowOpened(WindowEvent arg0)
{
}
}

View File

@@ -1,94 +0,0 @@
/*******************************************************************************
* SDR Trunk
* Copyright (C) 2014 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
******************************************************************************/
package io.github.dsheirer.spectrum;
import java.awt.image.IndexColorModel;
public class WaterfallColorModel
{
public static IndexColorModel getDefaultColorModel()
{
int bitDepth = 8;
int indexSize = 256;
byte[] red = new byte[ indexSize ];
byte[] green = new byte[ indexSize ];
byte[] blue = new byte[ indexSize ];
//Background noise
for( int x = 0; x < 16; x++ ) //Blue
{
red[ x ] = (byte)0;
green[ x ] = (byte)0;
blue[ x ] = (byte)127;
}
for( int x = 16; x < 32; x++ ) //Blue
{
red[ x ] = (byte)0;
green[ x ] = (byte)0;
blue[ x ] = (byte)191;
}
int r = 0;
int g = 0;
int b = 191;
for( int x = 32; x < 60; x++ )
{
red[ x ] = (byte)r;
r += 9;
green[ x ] = (byte)g;
g += 9;
blue[ x ] = (byte)b;
b -= 6;
}
r = 255;
g = 255;
b = 0;
for( int x = 60; x < 188; x++ ) //Yellow
{
red[ x ] = (byte)r;
green[ x ] = (byte)g;
g -= 2;
blue[ x ] = (byte)b;
}
for( int x = 188; x < 256; x++ ) //Red
{
red[ x ] = (byte)255;
green[ x ] = (byte)0;
blue[ x ] = (byte)0;
}
return new IndexColorModel( bitDepth, indexSize, red, green, blue );
}
}

View File

@@ -1,425 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.settings.ColorSetting;
import io.github.dsheirer.settings.ColorSetting.ColorSettingName;
import io.github.dsheirer.settings.Setting;
import io.github.dsheirer.settings.SettingChangeListener;
import io.github.dsheirer.settings.SettingsManager;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.geom.Line2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.IndexColorModel;
import java.text.DecimalFormat;
import java.util.Arrays;
import org.apache.commons.math3.util.FastMath;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JPanel;
public class WaterfallPanel extends JPanel implements DFTResultsListener,
Pausable,
SettingChangeListener
{
private static final long serialVersionUID = 1L;
private final static Logger mLog =
LoggerFactory.getLogger(WaterfallPanel.class);
private static DecimalFormat CURSOR_FORMAT = new DecimalFormat("0.00000");
private static final String PAUSED = "PAUSED - Right Click to Unpause";
private static final String DISABLED = "DISABLED - Right Click to Select a Tuner";
private byte[] mPixels;
private byte[] mPausedPixels;
private int mDFTSize = 4096;
private int mImageHeight = 700;
private IndexColorModel mColorModel = WaterfallColorModel.getDefaultColorModel();
private Color mColorSpectrumCursor;
private BufferedImage mVisibleImage;
private byte[] mVisiblePixels;
private boolean mIsDisposed = false;
private Point mCursorLocation = new Point(0, 0);
private boolean mCursorVisible = false;
private long mCursorFrequency = 0;
private boolean mPaused = false;
private boolean mDisabled = true;
private int mZoom = 0;
private int mDFTZoomWindowOffset = 0;
private SettingsManager mSettingsManager;
/**
* Displays a scrolling window of multiple DFT frequency bin outputs over
* time. Maps DFT frequency bin decibel values into a 256 bucket color map
* for display.
*
* @param settingsManager
*/
public WaterfallPanel(SettingsManager settingsManager)
{
super();
mSettingsManager = settingsManager;
mSettingsManager.addListener(this);
mColorSpectrumCursor = getColor(ColorSettingName.SPECTRUM_CURSOR);
reset();
}
/**
* Prepares this instance for disposal
*/
public void dispose()
{
if(mSettingsManager != null)
{
mSettingsManager.removeListener(this);
}
mSettingsManager = null;
mIsDisposed = true;
}
/**
* Resets the byte backing arrays when the DFT point size has changed
*/
private void reset()
{
mPixels = new byte[mDFTSize * mImageHeight];
if(mPaused)
{
mPausedPixels = mPixels.clone();
}
repaint();
}
/**
* Pausable interface - pauses updates to the waterfall
*/
public void setPaused(boolean paused)
{
if(paused)
{
mPausedPixels = mPixels.clone();
}
mPaused = paused;
repaint();
}
/**
* Returns current pause state
*
* @return true if paused, false otherwise
*/
public boolean isPaused()
{
return mPaused;
}
/**
* Indicates if the waterfall is currently disabled.
*/
public boolean isDisabled()
{
return mDisabled;
}
/**
* Sets the current zoom level (2^zoom)
*
* 0 No Zoom
* 1 2x Zoom
* 2 4x Zoom
* 3 8x Zoom
* 4 16x Zoom
* 5 32x Zoom
* 6 64x Zoom
*
* @param zoom level, 0 - 6.
*/
public void setZoom(int zoom)
{
mZoom = zoom;
}
/**
* Multiplier for the current zoom level
*/
private int getZoomMultiplier()
{
return (int) FastMath.pow(2.0, mZoom);
}
/**
* Sets the zoom window offset from zero
*
* @param offset in DFT bins
*/
public void setZoomWindowOffset(int offset)
{
mDFTZoomWindowOffset = offset;
}
/**
* Fetches a named color setting from the settings manager. If the setting
* doesn't exist, creates the setting using the defaultColor
*/
private Color getColor(ColorSettingName name)
{
ColorSetting setting = mSettingsManager.getColorSetting(name);
return setting.getColor();
}
/**
* Monitors for setting changes. Colors can be changed by external actions
* and will automatically update in this class
*/
@Override
public void settingChanged(Setting setting)
{
if(setting instanceof ColorSetting colorSetting)
{
if(colorSetting.getColorSettingName() == ColorSettingName.SPECTRUM_CURSOR)
{
mColorSpectrumCursor = colorSetting.getColor();
}
}
}
@Override
public void settingDeleted(Setting setting)
{ /* Not implemented */ }
/**
* Sets the display location of the cursor. Cursor location monitoring is
* handled external to this class.
*
* @param point
*/
public void setCursorLocation(Point point)
{
mCursorLocation = point;
repaint();
}
/**
* Sets the current cursor display frequency. Cursor location frequency
* monitoring is handled external to this class.
*
* @param frequency
*/
public void setCursorFrequency(long frequency)
{
mCursorFrequency = frequency;
}
/**
* Toggles the visibility of the cursor
*
* @param visible
*/
public void setCursorVisible(boolean visible)
{
mCursorVisible = visible;
repaint();
}
/**
* Copies the DFT bins that are visible at the current zoom level and offset into an image that is sized to hold
* only those bins. The whole waterfall can't be scaled to the zoomed width and clipped to the panel because the
* XRender pipeline silently renders nothing once the scaled width of the source image reaches 65,536 pixels.
*
* The image's pixel array is accessed directly, so Java2D doesn't cache it and each paint renders the current bins.
*
* @param multiplier - current zoom multiplier
*/
private void updateVisibleImage(int multiplier)
{
int bins = mDFTSize / multiplier;
if(mVisibleImage == null || mVisibleImage.getWidth() != bins)
{
mVisibleImage = new BufferedImage(bins, mImageHeight, BufferedImage.TYPE_BYTE_INDEXED, mColorModel);
mVisiblePixels = ((DataBufferByte)mVisibleImage.getRaster().getDataBuffer()).getData();
}
byte[] source = mPaused ? mPausedPixels : mPixels;
//The display is shifted one bin to the left, to align with the spectrum. Bins past the end are set to zero.
int firstBin = Math.min(mDFTZoomWindowOffset + 1, mDFTSize);
int length = Math.min(bins, mDFTSize - firstBin);
for(int row = 0; row < mImageHeight; row++)
{
int destination = row * bins;
System.arraycopy(source, (row * mDFTSize) + firstBin, mVisiblePixels, destination, length);
Arrays.fill(mVisiblePixels, destination + length, destination + bins, (byte)0);
}
}
/**
* Renders the screen at each refresh
*/
public void paintComponent(Graphics g)
{
super.paintComponent(g);
updateVisibleImage(getZoomMultiplier());
g.drawImage(mVisibleImage, 0, 0, getWidth(), mImageHeight, null);
Graphics2D graphics = (Graphics2D)g;
graphics.setColor(mColorSpectrumCursor);
if(mCursorVisible)
{
graphics.draw(new Line2D.Float(mCursorLocation.x, 0, mCursorLocation.x, (float)(getSize().getHeight())));
String frequency = CURSOR_FORMAT.format(mCursorFrequency / 1000000.0D);
graphics.drawString(frequency, mCursorLocation.x + 5, mCursorLocation.y);
}
if(mDisabled)
{
graphics.drawString(DISABLED, 20, 20);
}
else if(mPaused)
{
graphics.drawString(PAUSED, 20, 20);
}
paintZoomIndicator(graphics);
graphics.dispose();
}
/**
* When zoom level is greater than zero, paints a small indicator at the
* bottom center of the screen showing the location of the zoom window
* within the overall DFT results window
*/
private void paintZoomIndicator(Graphics2D graphics)
{
if(mZoom != 0)
{
int width = getWidth() / 4;
int x = (getWidth() / 2) - (width / 2);
//Draw the outer window
graphics.drawRect(x, getHeight() - 12, width, 10);
int zoomWidth = width / getZoomMultiplier();
int windowOffset = 0;
if(mDFTZoomWindowOffset != 0)
{
windowOffset = (int)(((double)mDFTZoomWindowOffset / (double)mDFTSize) * width);
}
//Draw the zoom window
graphics.fillRect(x + windowOffset, getHeight() - 12, zoomWidth, 10);
//Draw the zoom text
graphics.drawString("Zoom: " + getZoomMultiplier() + "x", x + width + 3, getHeight() - 2);
}
}
/**
* Implements the DFT results listener interface method. This is the primary method for receiving new frequency bin results.
*/
@Override
public void receive(float[] update)
{
mDisabled = false;
byte[] newPixels = new byte[update.length];
/**
* Find the average value and scale the display to it
*/
double sum = 0.0d;
for(int x = 0; x < update.length - 1; x++)
{
sum += update[x];
}
float average = (float)(sum / (double)update.length - 1);
float scale = 256.0f / average;
for(int x = 0; x < update.length - 1; x++)
{
float value = (average - update[x]) * scale;
if(value < 0)
{
newPixels[x] = 0;
}
else if(value > 255)
{
newPixels[x] = (byte)255;
}
else
{
newPixels[x] = (byte)value;
}
}
//Task the swing event thread to add the new pixels to the pixel array and update the display
EventQueue.invokeLater(() -> {
if(!mIsDisposed)
{
//If our FFT size changes, reset our pixel map
if(mDFTSize != newPixels.length)
{
mDFTSize = newPixels.length;
reset();
}
//Move the pixels down a row and add in the new pixels row
System.arraycopy(mPixels, 0, mPixels, mDFTSize, mPixels.length - mDFTSize);
System.arraycopy(newPixels, 0, mPixels, 0, newPixels.length);
repaint();
}
});
}
public void clearWaterfall()
{
mDisabled = true;
EventQueue.invokeLater(() -> {
Arrays.fill(mPixels, (byte)0);
if(mPausedPixels != null)
{
Arrays.fill(mPausedPixels, (byte)0);
}
repaint();
});
}
}

View File

@@ -67,9 +67,22 @@ public class ComplexDecibelConverter extends DFTResultsConverter
}
public static float[] convert(float[] results)
{
return convert(results, 1.0f / (float)(results.length / 2));
}
/**
* Converts the output of the JTransforms FloatFFT_1D.complexForward() calculation into decibels, with each bin's
* power (magnitude squared) multiplied by the scale factor, and swaps the upper and lower halves of the results
* so the lowest frequency is first.
* @param results interleaved complex DFT results
* @param powerScale to multiply each bin's power by before converting to decibels
* @return decibel value for each bin, half the length of the results
*/
public static float[] convert(float[] results, float powerScale)
{
int halfResults = results.length / 2;
float dftBinSizeScalor = 1.0f / (float) halfResults;
float dftBinSizeScalor = powerScale;
float[] processed = new float[halfResults];
int middle = processed.length / 2;

View File

@@ -0,0 +1,57 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum.converter;
/**
* Converts complex DFT results to decibels relative to full scale (dBFS). A complex tone with an amplitude of 1.0
* (the full scale of the normalized samples) centered in a DFT bin converts to 0 dBFS.
*
* Use with a ComplexDftProcessor whose window is scaled to unity coherent gain (setUnityGainWindow(true)). With
* unity coherent gain the DFT magnitude of a full scale tone equals the DFT size N, so each bin's power is divided
* by N squared.
*
* Noise spreads across the bins, so the noise floor of each bin is lower than the total noise power by the DFT
* processing gain: 10 * log10(N / window noise bandwidth in bins). Doubling the DFT size lowers the noise floor by
* about 3 dB while tones stay at the same level.
*/
public class FullScaleDecibelConverter extends DFTResultsConverter
{
/**
* Constructs an instance.
*/
public FullScaleDecibelConverter()
{
}
/**
* Converts interleaved complex DFT results to dBFS, with the lowest frequency first.
*/
public static float[] convert(float[] results)
{
double size = results.length / 2.0;
return ComplexDecibelConverter.convert(results, (float)(1.0 / (size * size)));
}
@Override
public void receive(float[] results)
{
dispatch(convert(results));
}
}

View File

@@ -1,57 +0,0 @@
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.spectrum.SpectralDisplayAdjuster;
import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
public class AveragingItem extends JSlider implements ChangeListener
{
private static final long serialVersionUID = 1L;
private SpectralDisplayAdjuster mAdjuster;
private int mDefaultValue;
public AveragingItem( SpectralDisplayAdjuster adjuster, int defaultValue )
{
super( JSlider.HORIZONTAL, 1, 20, adjuster.getAveraging() );
mDefaultValue = defaultValue;
mAdjuster = adjuster;
setMajorTickSpacing( 5 );
setMinorTickSpacing( 1 );
setPaintTicks( true );
setPaintLabels( true );
addChangeListener( this );
addMouseListener( new MouseListener()
{
@Override
public void mouseClicked( MouseEvent event )
{
if( event.getClickCount() == 2 )
{
AveragingItem.this.setValue( mDefaultValue );
}
}
public void mouseReleased( MouseEvent arg0 ) {}
public void mousePressed( MouseEvent arg0 ) {}
public void mouseExited( MouseEvent arg0 ) {}
public void mouseEntered( MouseEvent arg0 ) {}
} );
}
@Override
public void stateChanged( ChangeEvent event )
{
int value = ((JSlider)event.getSource()).getValue();
mAdjuster.setAveraging( value );
}
}

View File

@@ -1,38 +0,0 @@
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.spectrum.DFTSize;
import io.github.dsheirer.spectrum.IDFTWidthChangeProcessor;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class DFTSizeItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private IDFTWidthChangeProcessor mDFTProcessor;
private DFTSize mDFTSize;
public DFTSizeItem( IDFTWidthChangeProcessor processor, DFTSize size )
{
super( size.getLabel() );
mDFTProcessor = processor;
mDFTSize = size;
if( processor.getDFTSize() == mDFTSize )
{
setSelected( true );
}
addActionListener( new ActionListener()
{
@Override
public void actionPerformed( ActionEvent arg0 )
{
mDFTProcessor.setDFTSize( mDFTSize );
}
} );
}
}

View File

@@ -1,57 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.spectrum.ComplexDftProcessor;
import javax.swing.JCheckBoxMenuItem;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class FFTWindowTypeItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private ComplexDftProcessor mComplexDftProcessor;
private WindowType mWindowType;
public FFTWindowTypeItem(ComplexDftProcessor processor, WindowType windowType)
{
super(windowType.toString());
mComplexDftProcessor = processor;
mWindowType = windowType;
if(processor.getWindowType() == mWindowType)
{
setSelected(true);
}
addActionListener(new ActionListener()
{
@Override public void actionPerformed(ActionEvent arg0)
{
mComplexDftProcessor.setWindowType(mWindowType);
}
});
}
}

View File

@@ -1,57 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.spectrum.ComplexDftProcessor;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class FrameRateItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private ComplexDftProcessor mComplexDftProcessor;
private int mFrameRate;
public FrameRateItem(ComplexDftProcessor processor, int frameRate )
{
super( String.valueOf( frameRate ) );
mComplexDftProcessor = processor;
mFrameRate = frameRate;
if( processor.getFrameRate() == mFrameRate )
{
setSelected( true );
}
addActionListener( new ActionListener()
{
@Override
public void actionPerformed( ActionEvent arg0 )
{
mComplexDftProcessor.setFrameRate( mFrameRate );
}
} );
}
}

View File

@@ -1,107 +0,0 @@
/*
* ******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2019 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* *****************************************************************************
*/
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter;
import io.github.dsheirer.spectrum.SpectralDisplayAdjuster;
import javax.swing.JLabel;
import javax.swing.JSlider;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.Hashtable;
public class SmoothingItem extends JSlider implements ChangeListener
{
private static final long serialVersionUID = 1L;
private SpectralDisplayAdjuster mAdjuster;
private int mDefaultValue;
public SmoothingItem(SpectralDisplayAdjuster adjuster, int defaultValue)
{
super(JSlider.HORIZONTAL,
SmoothingFilter.SMOOTHING_MINIMUM,
SmoothingFilter.SMOOTHING_MAXIMUM,
adjuster.getSmoothing());
mDefaultValue = defaultValue;
mAdjuster = adjuster;
setSnapToTicks(true);
setMajorTickSpacing(6);
setMinorTickSpacing(2);
setPaintTicks(true);
setPaintLabels(true);
Hashtable<Integer,JLabel> labels = new Hashtable<>();
labels.put(3, new JLabel("3"));
labels.put(9, new JLabel("9"));
labels.put(15, new JLabel("15"));
labels.put(21, new JLabel("21"));
labels.put(27, new JLabel("27"));
setLabelTable(labels);
addChangeListener(this);
addMouseListener(new MouseListener()
{
@Override
public void mouseClicked(MouseEvent event)
{
if(event.getClickCount() == 2)
{
SmoothingItem.this.setValue(mDefaultValue);
}
}
public void mouseReleased(MouseEvent arg0)
{
}
public void mousePressed(MouseEvent arg0)
{
}
public void mouseExited(MouseEvent arg0)
{
}
public void mouseEntered(MouseEvent arg0)
{
}
});
}
@Override
public void stateChanged(ChangeEvent event)
{
int value = ((JSlider)event.getSource()).getValue();
if(value % 2 == 1)
{
mAdjuster.setSmoothing(value);
}
}
}

View File

@@ -1,35 +0,0 @@
package io.github.dsheirer.spectrum.menu;
import io.github.dsheirer.dsp.filter.smoothing.SmoothingFilter.SmoothingType;
import io.github.dsheirer.spectrum.SpectralDisplayAdjuster;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class SmoothingTypeItem extends JCheckBoxMenuItem
{
private static final long serialVersionUID = 1L;
private SpectralDisplayAdjuster mAdjuster;
private SmoothingType mSmoothingType;
public SmoothingTypeItem( SpectralDisplayAdjuster adjuster, SmoothingType type )
{
super( type.name() );
mAdjuster = adjuster;
mSmoothingType = type;
setSelected( mAdjuster.getSmoothingType() == type );
addActionListener( new ActionListener()
{
@Override
public void actionPerformed( ActionEvent e )
{
mAdjuster.setSmoothingType( mSmoothingType );
}
} );
}
}

View File

@@ -0,0 +1,256 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
/* Spectral display configuration panel. The panel floats over the (normally black) spectrum, so it uses the same
dark translucent style with both the light and the dark application themes. */
.spectral-config-panel
{
-fx-base: #3a3d41;
-fx-background: #26282b;
-fx-control-inner-background: #303236;
-fx-control-inner-background-alt: #383a3e;
-fx-accent: #4a90d9;
-fx-focus-color: #4a90d9;
-fx-faint-focus-color: #4a90d922;
-fx-text-base-color: #ececec;
-fx-text-background-color: #ececec;
-fx-mark-color: #ececec;
-fx-background-color: rgba(28, 30, 33, 0.84);
-fx-background-radius: 8;
-fx-border-color: rgba(255, 255, 255, 0.16);
-fx-border-radius: 8;
-fx-border-width: 1;
-fx-padding: 4;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.45), 8, 0, 0, 2);
}
.spectral-config-panel.collapsed
{
-fx-background-color: rgba(28, 30, 33, 0.55);
-fx-padding: 5;
-fx-cursor: hand;
}
.spectral-config-panel .label
{
-fx-text-fill: #dcdcdc;
}
.spectral-config-panel .spectral-config-title
{
-fx-font-weight: bold;
-fx-text-fill: #ffffff;
}
.spectral-config-panel .spectral-config-section
{
-fx-font-weight: bold;
-fx-text-fill: #8fbaf0;
-fx-padding: 6 0 0 0;
}
.spectral-config-panel .spectral-config-icon
{
-fx-fill: #e0e0e0;
}
.spectral-config-panel .spectral-config-pin
{
-fx-background-color: transparent;
-fx-padding: 2 5 2 5;
}
.spectral-config-panel .spectral-config-pin:selected
{
-fx-background-color: #4a90d9;
-fx-background-radius: 4;
}
.spectral-config-panel .spectral-config-scroll,
.spectral-config-panel .spectral-config-scroll > .viewport
{
-fx-background-color: transparent;
-fx-background-insets: 0;
-fx-padding: 0;
}
.spectral-config-panel .toggle-switch > .thumb-area
{
-fx-background-color: #45484c;
-fx-border-color: #5a5e62;
}
.spectral-config-panel .toggle-switch:selected > .thumb-area
{
-fx-background-color: #4a90d9;
-fx-border-color: #4a90d9;
}
.spectral-config-panel .toggle-switch > .thumb
{
-fx-background-color: #ececec;
}
/* Spectral display split pane between the spectrum and the waterfall */
.spectral-display-split-pane
{
-fx-padding: 0;
-fx-background-color: black;
-fx-background-insets: 0;
-fx-box-border: transparent;
}
.spectral-display-split-pane > .split-pane-divider
{
-fx-background-color: #3a3d41;
-fx-padding: 0 0 2 0;
}
.spectral-config-panel .spectral-config-hint
{
-fx-text-fill: #a0a8b4;
-fx-font-size: 0.9em;
}
/* Edge slider: thin strip along the right edge of the display that expands into a vertical slider on mouse over */
.spectral-edge-slider
{
-fx-base: #3a3d41;
-fx-background: #26282b;
-fx-accent: #4a90d9;
-fx-focus-color: #4a90d9;
-fx-faint-focus-color: #4a90d922;
-fx-cursor: hand;
}
.spectral-edge-slider-strip
{
-fx-background-color: rgba(200, 205, 212, 0.28);
-fx-background-radius: 2;
}
.spectral-edge-slider-marker
{
-fx-background-color: #4a90d9;
-fx-background-radius: 2;
-fx-border-color: rgba(255, 255, 255, 0.7);
-fx-border-radius: 2;
-fx-border-width: 1;
}
.spectral-edge-slider-panel
{
-fx-background-color: rgba(28, 30, 33, 0.84);
-fx-background-radius: 8 0 0 8;
-fx-border-color: rgba(255, 255, 255, 0.16);
-fx-border-radius: 8 0 0 8;
-fx-border-width: 1 0 1 1;
-fx-padding: 6 4 6 4;
-fx-cursor: default;
}
.spectral-edge-slider-title
{
-fx-font-weight: bold;
-fx-font-size: 0.9em;
-fx-text-fill: #8fbaf0;
}
.spectral-edge-slider-value
{
-fx-font-size: 0.85em;
-fx-text-fill: #dcdcdc;
}
/* Channel symbol view (ScatterChart) colored by the spectral display theme. SymbolView.setTheme() defines the
looked-up colors on the view. Defaults match the Classic theme. */
.symbol-view
{
symbol-background: #000000;
symbol-color: #ffffffcc;
symbol-grid: #c0c0c02d;
symbol-text: #c0c0c0;
-fx-background-color: symbol-background;
}
.symbol-view .chart,
.symbol-view .chart-content
{
-fx-background-color: symbol-background;
}
.symbol-view .chart-plot-background
{
-fx-background-color: symbol-background;
}
.symbol-view .chart-vertical-grid-lines,
.symbol-view .chart-horizontal-grid-lines
{
-fx-stroke: symbol-grid;
}
.symbol-view .chart-vertical-zero-line,
.symbol-view .chart-horizontal-zero-line
{
-fx-stroke: symbol-grid;
}
.symbol-view .chart-symbol
{
-fx-background-color: symbol-color;
-fx-background-radius: 2px;
-fx-padding: 1px;
}
.symbol-view .axis
{
-fx-tick-label-fill: symbol-text;
-fx-tick-mark-stroke: symbol-grid;
AXIS_COLOR: symbol-grid;
-fx-background-color: transparent;
}
.symbol-view .axis-label,
.symbol-view .chart-title
{
-fx-text-fill: symbol-text;
}
.symbol-view .chart-legend
{
-fx-background-color: transparent;
}
.symbol-view .chart-legend-item
{
-fx-text-fill: symbol-text;
}
/* The dark application theme colors every Text node, which would override the label text fill */
.symbol-view .chart-legend-item .text,
.symbol-view .axis-label .text,
.symbol-view .chart-title .text
{
-fx-fill: symbol-text;
}

View File

@@ -0,0 +1,140 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectral display frequency, bin and screen position conversions.
*/
public class SpectralDisplayModelTest
{
private static SpectralDisplayModel model(long center, int sampleRate, int bins)
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.centerFrequencyProperty().set(center);
model.sampleRateProperty().set(sampleRate);
model.setBinCount(bins);
return model;
}
@Test
void centerBinIsAtCenterFrequency()
{
SpectralDisplayModel model = model(100_000_000, 2_048_000, 4096);
double width = 1024;
//Bin 2048 (the DC bin) is centered at x = (2048 + 0.5) * pixels per bin
double x = (2048 + 0.5) * model.getPixelsPerBin(width);
assertEquals(100_000_000, model.getFrequency(x, width), 1);
assertEquals(x, model.getX(100_000_000, width), 1E-6);
}
@Test
void frequencyAndPositionRoundTrip()
{
SpectralDisplayModel model = model(851_000_000, 10_000_000, 8192);
double width = 1500;
for(int zoom = 0; zoom <= SpectralDisplayModel.MAX_ZOOM; zoom++)
{
model.setZoom(zoom, 852_000_000, 0.25);
for(double x = 0; x <= width; x += 37.5)
{
long frequency = model.getFrequency(x, width);
assertEquals(x, model.getX(frequency, width), 0.01, "zoom " + zoom);
}
}
}
@Test
void zoomKeepsFrequencyUnderMouse()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
double width = 1200;
double x = 900;
long frequency = model.getFrequency(x, width);
model.setZoom(3, frequency, x / width);
assertEquals(8, model.getZoomMultiplier());
assertEquals(4096 / 8, model.getVisibleBinCount());
//Within one bin width of the original frequency
assertEquals(frequency, model.getFrequency(x, width), model.getBinWidth());
}
@Test
void zoomOffsetIsLimitedToTheBins()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(2, model.getMaxFrequency(), 0.0);
assertEquals(4096 - 1024, model.getZoomOffset(), 1E-9);
model.setZoomOffset(-50);
assertEquals(0, model.getZoomOffset(), 1E-9);
model.setZoom(0, model.getCenterFrequency(), 0.5);
assertEquals(0, model.getZoomOffset(), 1E-9);
}
@Test
void zoomIsLimited()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(99, model.getCenterFrequency(), 0.5);
assertEquals(SpectralDisplayModel.MAX_ZOOM, model.getZoom());
model.setZoom(-5, model.getCenterFrequency(), 0.5);
assertEquals(SpectralDisplayModel.MIN_ZOOM, model.getZoom());
}
@Test
void binCountChangeKeepsLeftEdgeFrequency()
{
SpectralDisplayModel model = model(460_000_000, 2_400_000, 4096);
model.setZoom(2, 460_300_000, 0.0);
double left = model.getMinFrequency() + model.getZoomOffset() * model.getBinWidth();
model.setBinCount(16384);
double after = model.getMinFrequency() + model.getZoomOffset() * model.getBinWidth();
assertEquals(left, after, 1.0);
assertTrue(model.getZoomOffset() <= model.getBinCount() - model.getVisibleBinCount());
}
@Test
void dynamicRangeIncludesProcessingGain()
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.sampleSizeProperty().set(12.0);
model.setBinCount(4096);
model.noiseBandwidthProperty().set(2.0);
//66.23 dB sample range + 10 * log10(4096 / 2) = 33.11 dB processing gain
assertEquals(99.34, model.getDynamicRange(), 0.01);
model.setBinCount(8192);
assertEquals(102.35, model.getDynamicRange(), 0.01);
}
@Test
void dynamicRangeFollowsSampleSize()
{
SpectralDisplayModel model = new SpectralDisplayModel();
model.sampleSizeProperty().set(12.0);
assertEquals(66.22, model.getDynamicRange(), 0.01);
}
}

View File

@@ -0,0 +1,72 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectral display themes and waterfall palettes.
*/
public class SpectralDisplayThemeTest
{
@Test
void gradientEndsAtTheFirstAndLastColors()
{
int[] palette = WaterfallPalette.gradient("#000000", "#FF8000", "#FFFFFF");
assertEquals(WaterfallPalette.SIZE, palette.length);
assertEquals(0xFF000000, palette[0]);
assertEquals(0xFFFFFFFF, palette[WaterfallPalette.SIZE - 1]);
}
@Test
void classicPaletteMatchesTheOriginalWaterfall()
{
int[] palette = WaterfallPalette.classic();
assertEquals(0xFF00007F, palette[0]);
assertEquals(0xFF0000BF, palette[16]);
assertEquals(0xFFFFFF00, palette[60]);
assertEquals(0xFFFF0000, palette[255]);
}
@Test
void everyThemeHasAnOpaqueWaterfallAndALabel()
{
for(SpectralDisplayTheme theme: SpectralDisplayTheme.values())
{
assertTrue(theme.getLabel() != null && !theme.getLabel().isEmpty(), theme.name());
assertEquals(1.0, theme.getBackground().getOpacity(), 1E-9, theme.name());
for(int index = 0; index < WaterfallPalette.SIZE; index++)
{
assertEquals(0xFF, theme.getWaterfallColor(index) >>> 24, theme.name() + " index " + index);
}
}
}
@Test
void negativeByteIndexesMapToTheUpperPalette()
{
SpectralDisplayTheme theme = SpectralDisplayTheme.CLASSIC;
assertEquals(theme.getWaterfallColor(255), theme.getWaterfallColor((byte)255));
}
}

View File

@@ -0,0 +1,147 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the spectrum attack/decay averaging and peak hold.
*/
public class SpectrumAveragerTest
{
/**
* Noise power in a DFT bin is exponentially distributed. Averaging its dB values reads about 2.5 dB below the
* mean power. Averaging linear power (symmetric rates) should read the mean power.
*/
@Test
void linearPowerAveragingMeasuresMeanNoisePower()
{
Random random = new Random(3);
int bins = 2048;
double meanPower = 1E-6; //-60 dB
double linearSum = 0.0;
double decibelSum = 0.0;
int count = 0;
for(int frame = 0; frame < 4000; frame++)
{
for(int x = 0; x < bins; x++)
{
double power = -meanPower * Math.log(1.0 - random.nextDouble());
double decibels = 10.0 * Math.log10(power);
if(frame >= 200)
{
linearSum += power;
decibelSum += decibels;
count++;
}
}
}
assertEquals(-60.0, 10.0 * Math.log10(linearSum / count), 0.05);
//The bias of averaging decibel values that the linear averaging avoids
assertEquals(-62.5, decibelSum / count, 0.1);
}
@Test
void steadyInputSettlesAtTheInputLevel()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(10);
float[] frame = {-80f, -40f, -60f};
SpectrumAverager.Frame result = null;
for(int x = 0; x < 200; x++)
{
result = averager.process(frame);
}
assertEquals(-80f, result.average()[0], 0.01f);
assertEquals(-40f, result.average()[1], 0.01f);
assertEquals(-60f, result.average()[2], 0.01f);
}
@Test
void signalsRiseFasterThanTheyFall()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(10);
averager.process(new float[]{-80f});
//One frame of a strong signal rises 2.5x as far (in linear power) as one frame of it disappearing falls
float risen = averager.process(new float[]{-20f}).average()[0];
double risePower = SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(-80f);
assertEquals(0.25 * (SpectrumAverager.toPower(-20f) - SpectrumAverager.toPower(-80f)), risePower, 1E-6);
float fallen = averager.process(new float[]{-80f}).average()[0];
double fallPower = SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(fallen);
assertEquals(0.1 * (SpectrumAverager.toPower(risen) - SpectrumAverager.toPower(-80f)), fallPower, 1E-6);
}
@Test
void fastestSettingFollowsTheInput()
{
SpectrumAverager averager = new SpectrumAverager();
averager.setAveraging(SpectrumAverager.AVERAGING_MINIMUM);
averager.process(new float[]{-80f});
assertEquals(-20f, averager.process(new float[]{-20f}).average()[0], 0.001f);
assertEquals(-90f, averager.process(new float[]{-90f}).average()[0], 0.001f);
}
@Test
void peakHoldHoldsAndFalls()
{
SpectrumAverager averager = new SpectrumAverager();
assertNull(averager.process(new float[]{-80f}).peak());
averager.setPeakHold(true);
assertEquals(-30f, averager.process(new float[]{-30f}).peak()[0], 0.001f);
float[] peak = null;
for(int x = 0; x < 10; x++)
{
peak = averager.process(new float[]{-80f}).peak();
}
assertEquals(-30f - (10 * SpectrumAverager.PEAK_DECAY_DB), peak[0], 0.001f);
assertTrue(peak[0] > -80f);
averager.resetPeak();
assertEquals(-80f, averager.process(new float[]{-80f}).peak()[0], 0.001f);
averager.setPeakHold(false);
assertNull(averager.process(new float[]{-80f}).peak());
}
@Test
void resolutionLabels()
{
assertEquals("585.9 Hz/bin", SpectralDisplayConfigPanel.formatResolution(2_400_000.0 / 4096));
assertEquals("2.44 kHz/bin", SpectralDisplayConfigPanel.formatResolution(10_000_000.0 / 4096));
assertEquals("73.2 Hz/bin", SpectralDisplayConfigPanel.formatResolution(2_400_000.0 / 32768));
}
}

View File

@@ -0,0 +1,42 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests the decibel scale grid step.
*/
public class DecibelLayerTest
{
@Test
void stepKeepsLinesAtLeast28PixelsApart()
{
//100 dB over 300 pixels = 3 pixels per dB: 10 dB = 30 pixels
assertEquals(10.0, DecibelLayer.getStep(100, 300));
//100 dB over 1000 pixels = 10 pixels per dB: 5 dB = 50 pixels
assertEquals(5.0, DecibelLayer.getStep(100, 1000));
//100 dB over 100 pixels = 1 pixel per dB: 50 dB = 50 pixels
assertEquals(50.0, DecibelLayer.getStep(100, 100));
assertEquals(50.0, DecibelLayer.getStep(100, 0));
}
}

View File

@@ -0,0 +1,44 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests the frequency label spacing.
*/
public class FrequencyLayerTest
{
@Test
void niceStepRoundsUpToOneTwoOrFive()
{
assertEquals(1, FrequencyLayer.niceStep(0.3));
assertEquals(1, FrequencyLayer.niceStep(1));
assertEquals(2, FrequencyLayer.niceStep(1.5));
assertEquals(5, FrequencyLayer.niceStep(3));
assertEquals(10, FrequencyLayer.niceStep(7));
assertEquals(100_000, FrequencyLayer.niceStep(100_000));
assertEquals(200_000, FrequencyLayer.niceStep(120_000));
assertEquals(500_000, FrequencyLayer.niceStep(250_001));
assertEquals(1_000_000, FrequencyLayer.niceStep(600_000));
}
}

View File

@@ -0,0 +1,314 @@
/*
* *****************************************************************************
* 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.module.decode.p25;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import io.github.dsheirer.module.decode.p25.identifier.channel.APCO25Channel;
import io.github.dsheirer.module.decode.p25.identifier.channel.P25Channel;
import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25RadioIdentifier;
import io.github.dsheirer.module.decode.p25.identifier.talkgroup.APCO25Talkgroup;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.message.IFrequencyBand;
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.Opcode;
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
import io.github.dsheirer.module.decode.p25.phase2.message.mac.MacOpcode;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.p25.reference.ServiceOptions;
import io.github.dsheirer.module.decode.p25.reference.VoiceServiceOptions;
import io.github.dsheirer.source.config.SourceConfigTuner;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the ignore encrypted calls option of the P25 traffic channel manager.
*/
public class P25TrafficChannelManagerEncryptedTest
{
private static final long BASE = 851_000_000L;
private static final long SPACING = 12_500L;
private static final int TALKGROUP_A = 100;
private static final int TALKGROUP_B = 200;
/**
* Captures traffic channel start requests and decode events.
*/
private static class Harness
{
private final P25TrafficChannelManager mManager;
private final List<Long> mStartedFrequencies = new ArrayList<>();
private final List<IDecodeEvent> mEvents = new ArrayList<>();
Harness(DecodeConfigP25 config)
{
Channel parent = new Channel("control");
parent.setDecodeConfiguration(config);
mManager = new P25TrafficChannelManager(parent);
EventBus eventBus = new EventBus();
eventBus.register(this);
mManager.setInterModuleEventBus(eventBus);
mManager.addDecodeEventListener(mEvents::add);
}
@Subscribe
public void start(ChannelStartProcessingRequest request)
{
mStartedFrequencies.add(request.getChannel().getSourceConfiguration() instanceof
SourceConfigTuner sct ? sct.getFrequency() : -1L);
}
String lastDetails()
{
return mEvents.get(mEvents.size() - 1).getDetails();
}
}
private static DecodeConfigP25Phase1 phase1(boolean ignoreEncrypted)
{
DecodeConfigP25Phase1 config = new DecodeConfigP25Phase1();
config.setIgnoreEncryptedCalls(ignoreEncrypted);
return config;
}
private static DecodeConfigP25Phase2 phase2(boolean ignoreEncrypted)
{
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
config.setIgnoreEncryptedCalls(ignoreEncrypted);
return config;
}
/**
* Frequency band where the downlink frequency for channel number N is BASE + (N / timeslots) * SPACING
*/
private static IFrequencyBand band(int timeslots)
{
return new IFrequencyBand()
{
@Override public boolean isValid() {return true;}
@Override public int getIdentifier() {return timeslots == 1 ? 1 : 2;}
@Override public long getChannelSpacing() {return SPACING;}
@Override public long getBaseFrequency() {return BASE;}
@Override public int getBandwidth() {return 12500;}
@Override public long getTransmitOffset() {return 0;}
@Override public long getDownlinkFrequency(int channelNumber) {return BASE + (channelNumber / timeslots) * SPACING;}
@Override public long getUplinkFrequency(int channelNumber) {return getDownlinkFrequency(channelNumber);}
@Override public boolean isTDMA() {return timeslots > 1;}
@Override public int getTimeslotCount() {return timeslots;}
};
}
private static APCO25Channel fdma(int channelNumber)
{
P25Channel channel = new P25Channel(1, channelNumber);
channel.setFrequencyBand(band(1));
return new APCO25Channel(channel);
}
/**
* @param logicalChannel number (ie frequency index)
* @param timeslot 1 or 2
*/
private static APCO25Channel tdma(int logicalChannel, int timeslot)
{
P25Channel channel = new P25Channel(2, logicalChannel * 2 + (timeslot - 1));
channel.setFrequencyBand(band(2));
return new APCO25Channel(channel);
}
private static MutableIdentifierCollection ic(int talkgroup, Integer radio)
{
MutableIdentifierCollection mic = new MutableIdentifierCollection();
mic.update(APCO25Talkgroup.create(talkgroup));
if(radio != null)
{
mic.update(APCO25RadioIdentifier.createFrom(radio));
}
return mic;
}
private static void p1Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts)
{
h.mManager.processP1ControlDirectedChannelGrant(channel, so, ic(tg, radio), Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT, ts, "test");
}
private static void p1Update(Harness h, APCO25Channel channel, int tg, long ts)
{
h.mManager.processP1ControlAnnouncedTrafficUpdate(channel, null, ic(tg, null),
Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT_UPDATE, ts, "test");
}
private static void p2Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts)
{
h.mManager.processP2ChannelGrant(channel, so, ic(tg, radio), MacOpcode.PHASE1_C0_GROUP_VOICE_CHANNEL_GRANT_EXPLICIT, ts, "test");
}
/**
* Mirrors the decoder which uses empty (ie unencrypted) service options for updates that don't carry them.
*/
private static void p2Update(Harness h, APCO25Channel channel, int tg, long ts)
{
h.mManager.processP2ChannelUpdate(channel, new VoiceServiceOptions(0), ic(tg, null),
MacOpcode.PHASE1_42_GROUP_VOICE_CHANNEL_GRANT_UPDATE_IMPLICIT, ts, "test");
}
@Test
void phase1EncryptedGrantIsTunedWhenOptionDisabled()
{
Harness h = new Harness(phase1(false));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase1EncryptedGrantIsIgnoredIncludingLaterUpdates()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p1Update(h, fdma(10), TALKGROUP_A, 1500);
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 2000);
assertTrue(h.mStartedFrequencies.isEmpty());
assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails());
}
@Test
void phase1ClearGrantIsTuned()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase1DifferentTalkerWithClearGrantIsTuned()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 2, 1500);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase2EncryptedGrantIsIgnoredIncludingLaterUpdates()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p2Update(h, tdma(5, 1), TALKGROUP_A, 1500);
assertTrue(h.mStartedFrequencies.isEmpty());
assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails());
}
@Test
void phase2ClearCallOnOtherTimeslotTunesChannel()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertTrue(h.mStartedFrequencies.isEmpty());
p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1100);
assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies);
//Further updates on either timeslot don't request another channel
p2Update(h, tdma(5, 1), TALKGROUP_A, 1500);
p2Update(h, tdma(5, 2), TALKGROUP_B, 1500);
assertEquals(1, h.mStartedFrequencies.size());
}
@Test
void phase2EncryptedGrantOnAlreadyTunedChannelIsNotLabeledIgnored()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1000);
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1100);
assertEquals(1, h.mStartedFrequencies.size());
assertTrue(h.lastDetails().startsWith("PHASE 2 CHANNEL GRANT"), h.lastDetails());
}
@Test
void phase2EncryptedGrantIsTunedWhenOptionDisabled()
{
Harness h = new Harness(phase2(false));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies);
}
private static Set<IDecodeEvent> distinctEvents(Harness h, int timeslot)
{
Set<IDecodeEvent> events = Collections.newSetFromMap(new IdentityHashMap<>());
for(IDecodeEvent event : h.mEvents)
{
if(event.getTimeslot() == timeslot)
{
events.add(event);
}
}
return events;
}
@Test
void phase2IgnoredDataCallOnTimeslot2IsTrackedOnTimeslot2()
{
DecodeConfigP25Phase2 config = phase2(false);
config.setIgnoreDataCalls(true);
Harness h = new Harness(config);
h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1),
Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1000, "test");
h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1),
Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1500, "test");
assertTrue(h.mStartedFrequencies.isEmpty());
assertEquals(1, distinctEvents(h, 2).size(), "repeated grant should update the timeslot 2 event");
assertTrue(distinctEvents(h, 1).isEmpty());
}
@Test
void phase2DataChannelBroadcastsBothTimeslotEvents()
{
Harness h = new Harness(phase2(false));
h.mManager.processP2DataChannel(tdma(5, 1), 1000);
assertEquals(1, distinctEvents(h, 1).size());
assertEquals(1, distinctEvents(h, 2).size());
}
@Test
void phase2CopyKeepsTrafficChannelSettings()
{
DecodeConfigP25Phase2 original = phase2(true);
original.setIgnoreDataCalls(true);
original.setTrafficChannelPoolSize(7);
DecodeConfigP25Phase2 copy = (DecodeConfigP25Phase2)DecoderFactory.copy(original);
assertTrue(copy.getIgnoreDataCalls());
assertTrue(copy.getIgnoreEncryptedCalls());
assertEquals(7, copy.getTrafficChannelPoolSize());
DecodeConfigP25Phase2 defaults = (DecodeConfigP25Phase2)DecoderFactory.copy(phase2(false));
assertFalse(defaults.getIgnoreDataCalls());
assertFalse(defaults.getIgnoreEncryptedCalls());
}
}

View File

@@ -0,0 +1,148 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.spectrum;
import io.github.dsheirer.dsp.window.WindowFactory;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter;
import java.util.Random;
import org.jtransforms.fft.FloatFFT_1D;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the dBFS computation: unity gain window, DFT and full scale decibel conversion, the same steps the
* spectral display's DFT processor uses.
*/
public class FullScaleDecibelTest
{
private static final WindowType[] WINDOWS = {WindowType.NONE, WindowType.HANN, WindowType.BLACKMAN_HARRIS_7,
WindowType.FLAT_TOP, WindowType.KAISER};
/**
* Windows, transforms and converts the interleaved samples to dBFS, in display order (DC at size / 2).
*/
private static float[] dbfs(float[] samples, WindowType window)
{
int size = samples.length / 2;
WindowFactory.apply(ComplexDftProcessor.createWindow(window, size, true), samples);
new FloatFFT_1D(size).complexForward(samples);
return FullScaleDecibelConverter.convert(samples);
}
private static float[] tone(int size, int bin, double amplitude)
{
float[] samples = new float[size * 2];
for(int k = 0; k < size; k++)
{
double phase = 2.0 * Math.PI * bin * k / size;
samples[2 * k] = (float)(amplitude * Math.cos(phase));
samples[2 * k + 1] = (float)(amplitude * Math.sin(phase));
}
return samples;
}
@Test
void fullScaleToneIsZeroDbfsForEverySizeAndWindow()
{
for(DFTSize size: DFTSize.values())
{
for(WindowType window: WINDOWS)
{
int n = size.getSize();
int bin = n / 8;
float[] decibels = dbfs(tone(n, bin, 1.0), window);
assertEquals(0.0, decibels[n / 2 + bin], 0.01, size + " " + window);
}
}
}
@Test
void toneLevelFollowsAmplitude()
{
int n = 4096;
//-20 dBFS tone at a negative frequency
float[] decibels = dbfs(tone(n, -300, 0.1), WindowType.BLACKMAN_HARRIS_7);
assertEquals(-20.0, decibels[n / 2 - 300], 0.01);
}
@Test
void noiseFloorIsLoweredByTheProcessingGain()
{
//Complex white noise with total power of 1E-4 (-40 dBFS)
double power = 1E-4;
double sigma = Math.sqrt(power / 2.0);
for(DFTSize size: new DFTSize[]{DFTSize.FFT01024, DFTSize.FFT08192})
{
for(WindowType window: new WindowType[]{WindowType.NONE, WindowType.BLACKMAN_HARRIS_7})
{
int n = size.getSize();
double sum = 0.0;
int frames = 8;
Random random = new Random(7);
for(int frame = 0; frame < frames; frame++)
{
float[] samples = new float[n * 2];
for(int x = 0; x < samples.length; x++)
{
samples[x] = (float)(random.nextGaussian() * sigma);
}
for(float decibel: dbfs(samples, window))
{
sum += Math.pow(10.0, decibel / 10.0);
}
}
double measured = 10.0 * Math.log10(sum / (frames * n));
double expected = -40.0 - 10.0 * Math.log10(n / ComplexDftProcessor.getNoiseBandwidth(window));
assertEquals(expected, measured, 0.3, size + " " + window);
}
}
}
@Test
void noiseBandwidthIsInTheExpectedRange()
{
assertEquals(1.0, ComplexDftProcessor.getNoiseBandwidth(WindowType.NONE), 0.001);
assertEquals(1.5, ComplexDftProcessor.getNoiseBandwidth(WindowType.HANN), 0.01);
double blackmanHarris = ComplexDftProcessor.getNoiseBandwidth(WindowType.BLACKMAN_HARRIS_7);
assertTrue(blackmanHarris > 2.0 && blackmanHarris < 3.5, "BH7 ENBW " + blackmanHarris);
}
@Test
void sameCoefficientIsAppliedToIAndQ()
{
float[] window = ComplexDftProcessor.createWindow(WindowType.HANN, 512, false);
assertEquals(1024, window.length);
for(int x = 0; x < 512; x++)
{
assertEquals(window[2 * x], window[2 * x + 1], 0.0f);
}
}
}