/* * ***************************************************************************** * Copyright (C) 2014-2026 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see * **************************************************************************** */ package io.github.dsheirer.gui.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; import io.github.dsheirer.module.ProcessingChain; import io.github.dsheirer.module.decode.FeedbackDecoder; import io.github.dsheirer.module.decode.PrimaryDecoder; import io.github.dsheirer.module.decode.am.AMDecoder; 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.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 java.awt.Component; import java.awt.EventQueue; import java.net.URL; import java.text.DecimalFormat; import java.util.List; import javafx.application.Platform; import javafx.embed.swing.JFXPanel; import javafx.scene.Scene; import net.miginfocom.swing.MigLayout; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JSplitPane; /** * Display for channel FFT and squelch details */ public class ChannelSpectrumPanel extends JPanel implements Listener { private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumPanel.class); private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000"); private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000"); private static final String LABEL_PREFIX_PPM = "Tuner PPM: "; private static final String LABEL_PREFIX_TUNER = "Tuner:"; private static final String LABEL_PREFIX_CHANNEL = "Channel:"; private static final String LABEL_PREFIX_DECODER = "Decoder:"; private final PlaylistManager mPlaylistManager; private ProcessingChain mProcessingChain; private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule(); 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 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 final NoiseSquelchView mNoiseSquelchView; private final SignalPowerView mSignalPowerView; private final SymbolView mSymbolView = new SymbolView(); private final JFXPanel mNoiseSquelchPanel; private final JFXPanel mSymbolPanel; private JSplitPane mSplitPane; private FeedbackDecoder mFeedbackDecoder; /** * Constructs an instance. */ public ChannelSpectrumPanel(PlaylistManager playlistManager) { mPlaylistManager = playlistManager; mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager); mSignalPowerView = new SignalPowerView(mPlaylistManager); //Register the three swappable right-pane components with the theme manager. Only one is //attached to the split pane at a time, so the LAF-toggle walk of Window.getWindows() can //miss the detached ones - they get refreshed explicitly via registerSwing. ThemeManager.getInstance().registerSwing(mSignalPowerView); setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]")); JPanel fftPanel = new JPanel(); fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]")); JPanel labelPanel = new JPanel(); labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", "")); mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM); mTunerPPMLabel.setEnabled(false); labelPanel.add(mTunerPPMLabel); labelPanel.add(new JLabel("Corrections (Hz) ")); mTunerCorrectionLabel = new JLabel(LABEL_PREFIX_TUNER); mTunerCorrectionLabel.setEnabled(false); labelPanel.add(mTunerCorrectionLabel); mChannelCorrectionLabel = new JLabel(LABEL_PREFIX_CHANNEL); mChannelCorrectionLabel.setEnabled(false); labelPanel.add(mChannelCorrectionLabel); mDecoderCorrectionLabel = new JLabel(LABEL_PREFIX_DECODER); mDecoderCorrectionLabel.setEnabled(false); labelPanel.add(mDecoderCorrectionLabel); labelPanel.add(mDummyLabel); JButton logIndexesButton = new JButton("Log Settings"); logIndexesButton.addActionListener(e -> { if(mProcessingChain != null) { Source source = mProcessingChain.getSource(); if(source instanceof PolyphaseChannelSource pcs) { List indexes = pcs.getOutputProcessorIndexes(); double sampleRate = pcs.getSampleRate(); long indexCenterFrequency = pcs.getIndexCenterFrequency(); long appliedFrequencyOffset = pcs.getFrequencyOffset(); long requestedCenterFrequency = pcs.getFrequency(); StringBuilder sb = new StringBuilder(); sb.append("Polyphase Channel - BW: ").append(FREQUENCY_FORMAT.format(sampleRate / 1E6d)); sb.append(" Center/Requested/Mixer: ").append(FREQUENCY_FORMAT.format(indexCenterFrequency / 1E6d)); sb.append("/").append(FREQUENCY_FORMAT.format(requestedCenterFrequency / 1E6d)); sb.append("/").append(FREQUENCY_FORMAT.format(appliedFrequencyOffset / 1E6d)); sb.append(" Polyphase Indexes: ").append(indexes); sb.append(" Tuner SR:").append(FREQUENCY_FORMAT.format(pcs.getTunerSampleRate() / 1E6d)); sb.append(" CF:").append(FREQUENCY_FORMAT.format(pcs.getTunerCenterFrequency() / 1E6d)); LOGGER.info(sb.toString()); LOGGER.info("Output Processor: " + pcs.getStateDescription()); } else if(source instanceof HalfBandTunerChannelSource hbtcs) { StringBuilder sb = new StringBuilder(); sb.append("Heterodyne Channel - CF:").append(FREQUENCY_FORMAT.format(hbtcs.getFrequency() / 1E6d)); sb.append(" SR:").append(FREQUENCY_FORMAT.format(hbtcs.getSampleRate() / 1E6d)); sb.append(" Mixer:").append(FREQUENCY_FORMAT.format(hbtcs.getMixerFrequency() / 1E6d)); LOGGER.info(sb.toString()); } else { LOGGER.info("Unsupported channel type: " + (source != null ? source.getClass() : " null")); } } }); //This is a debug button to log the current settings to the app log. // labelPanel.add(mLogIndexesButton); fftPanel.add(labelPanel, "wrap"); fftPanel.add(mSpectralPanel); ThemeManager.getInstance().registerSwing(mSpectralPanel); 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(); ThemeManager.getInstance().registerSwing(mNoiseSquelchPanel); ThemeManager.getInstance().registerSwing(mSymbolPanel); //Spin noise squelch panel construction off onto the JavafX UI thread. Platform.runLater(() -> { Scene scene = new Scene(mNoiseSquelchView); ThemeManager.getInstance().register(scene); mNoiseSquelchPanel.setScene(scene); Scene scene2 = new Scene(mSymbolView); URL resource = getClass().getResource("/sdrtrunk_style.css"); if(resource != null) { scene2.getStylesheets().add(resource.toExternalForm()); } else { LOGGER.warn("Can't find stylesheet resource for sdrtrunk"); } ThemeManager.getInstance().register(scene2); //Use the same color theme as the channel spectral display mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme()); mSymbolPanel.setScene(scene2); }); mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); mSplitPane.add(fftPanel, JSplitPane.LEFT); mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT); add(mSplitPane); // mSplitPane.setDividerLocation(0.5); } /** * 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); } } /** * Pads the value to a fixed width and returns the previx and padded value. * @param prefix of the label * @param value to pad with spaces * @return padded, prefixed label */ private String getPaddedLabel(String prefix, Number value) { return prefix + StringUtils.leftPad(String.valueOf(value), 7); } /** * Signals this panel to indicate if this panel is visible to turn on the FFT processor when the panel is visible * and turn off the FFT processor when it's not. * * Note: this method is intended to be called by the Swing event thread to ensure that only a single thread is * invoking either this method, or the receive() method, since there is no thread synchronization between these * two methods and they each depend on stable access to the mPanelVisible variable. * * @param visible true to indicate that this panel is showing/visible. */ public void setPanelVisible(boolean visible) { mPanelVisible = visible; updateFFTProcessing(); mNoiseSquelchView.setShowing(visible); mSymbolView.setShowing(visible); } /** * 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 synchronized void updateFFTProcessing() { if(mChannelSpectralDisplay == null) { return; } boolean active = mPanelVisible && mProcessingChain != null; if(active) { mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener()); } else { mSampleStreamTapModule.removeListener(); } mChannelSpectralDisplay.setActive(active); } private void broadcast(SourceEvent sourceEvent) { if(mProcessingChain != null) { mProcessingChain.broadcast(sourceEvent); } } /** * Resets controls when changing processing chain source. Note: this must be called on the Swing * dispatch thread because it directly invokes swing components. */ private void reset() { mTunerPPMLabel.setText(LABEL_PREFIX_PPM); mTunerPPMLabel.setEnabled(false); mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER); mTunerCorrectionLabel.setEnabled(false); mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL); mChannelCorrectionLabel.setEnabled(false); mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER); mDecoderCorrectionLabel.setEnabled(false); setChannel(0, 0, 0); } /** * Receive notifications of request to provide display of processing chain details. */ @Override public void receive(ProcessingChain processingChain) { //Disconnect the previous processing chain. if(mProcessingChain != null) { mNoiseSquelchView.setController(null); mSignalPowerView.setProcessingChain(null); mSymbolView.removeSymbolProvider(); mSymbolView.setProtocol(""); mProcessingChain.removeSourceEventListener(mSourceEventProcessor); mProcessingChain.removeModule(mSampleStreamTapModule); mFeedbackDecoder = null; } //Invoking reset - we're on the Swing dispatch thread here reset(); mProcessingChain = processingChain; if(mProcessingChain != null) { mProcessingChain.addSourceEventListener(mSourceEventProcessor); PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder(); if(primaryDecoder instanceof NBFMDecoder nbfmDecoder) { setRightComponent(mNoiseSquelchPanel); mNoiseSquelchView.setController(nbfmDecoder); } else if(primaryDecoder instanceof AMDecoder) { setRightComponent(mSignalPowerView); mSignalPowerView.setProcessingChain(mProcessingChain); } else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder) { setRightComponent(mSymbolPanel); mSymbolView.setSymbolProvider(feedbackDecoder); mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription()); mFeedbackDecoder = feedbackDecoder; } mProcessingChain.addModule(mSampleStreamTapModule); Source source = mProcessingChain.getSource(); int bandwidth = 0; Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain); if(channel != null) { List tunerChannels = channel.getTunerChannels(); if(!tunerChannels.isEmpty()) { bandwidth = tunerChannels.getFirst().getBandwidth(); } } if(source instanceof TunerChannelSource tcs) { setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth); } } updateFFTProcessing(); } /** * Shows the component on the right side of the split pane. * @param component to show. */ private void setRightComponent(Component component) { Component rightComponent = mSplitPane.getRightComponent(); if(rightComponent != component) { mSplitPane.remove(rightComponent); mSplitPane.setRightComponent(component); mSplitPane.setDividerLocation(0.4); } } /** * Processor for source event stream to capture power level and squelch related source events. */ private class SourceEventProcessor implements Listener { @Override public void receive(SourceEvent sourceEvent) { if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS) { if(sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status) { //Note: we flip the sign on the error measurement because the value represents the amount of offset the PLL //has to apply to move the signal to center/baseband EventQueue.invokeLater(() -> { mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM())); mTunerPPMLabel.setEnabled(true); if(status.isAutoPPM()) { mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection())); } else { mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled"); } mTunerCorrectionLabel.setEnabled(true); mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection())); mChannelCorrectionLabel.setEnabled(true); mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection())); mDecoderCorrectionLabel.setEnabled(true); //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. setCarrierOffset(-status.getDecoderCorrection()); }); } } //TODO: is this still needed? mSignalPowerView.receive(sourceEvent); } } }