Compare commits

..

13 Commits

Author SHA1 Message Date
denny
ac8e80dd36 #2660 Talker alias cleanup across the decoders 2026-11-15 07:35:03 -05:00
denny
e9207e4067 #2660 Talker alias cleanup across the decoders 2026-11-15 07:31:36 -05:00
denny
a4a3a67901 #2660 Now Playing panel with events and messages are all recreated as JavaFX panels 2026-11-15 06:19:40 -05:00
Denny Sheirer
c93d099742 #2658 DMR additional error detection and correction enhancements (#2659)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-15 04:04:34 -05:00
Denny Sheirer
be0966653b #2656 DMR Hamming and BPTC error detection and correction algorithm enhancements (#2657)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-15 03:46:55 -05:00
jpmetal
26a24fc6ad Fix the DMR EMB QR(16,7,6) codeword table (#2645)
All 64 entries with bit 15 set differed by an XOR of 0x0060 from the
codewords derived from the generator matrix in TS 102 361-1 table B.12, so
error-free EMBs for colour codes 8-15 were flagged invalid by the hard
decoder and "corrected" by the soft decoder.  Single bit errors in those
codewords, though within the correction capability, were reported as
uncorrectable for 13 of the 15 bit positions (87 %).

Co-authored-by: modificaz <jpmetal@freemail.gr>
2026-11-15 03:46:24 -05:00
Jared Szechy
9deabd0dcc Network tuner support - rtl_tcp (#2471)
* Add network tuner dialog

* wip: network tuner configs

* wip stash

* working tcp solution

* fix copyright headers

* fix: resolve rebase conflicts from jRTL library changeover

- Drop redundant JFrequencyControl digit-sizing fix now that upstream
  fixes the same issue via setColumns(1)
- Mark SUPPORTED_NETWORK_TUNERS final to match the new EnumSet style
- Replace removed SampleRate.getClosest() with a local nearest-match
  helper in NetworkTunerEditor

* Add mise config
2026-11-15 03:32:02 -05:00
Denny Sheirer
2db41393dc #2654 NXDN Audio Removes Additive Non-Clipping Gain and uses JMBE AGC only (#2655)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-15 02:15:33 -05:00
Denny Sheirer
29a10edf03 #2652 DMR audio module checks voice messages for at least one CRC-valid audio frame before processing audio and purges any enqueued audio on voice header to prepare for the upcoming call (#2653)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-15 01:47:46 -05:00
Denny Sheirer
5bce541631 #2641 Enhanced radio reference integration. With a premium subscription and when you set your location on the map, the Spectral display includes radio reference layer that presents popup card in the spectrum that shows candidate systems or frequencies ordered by distance from your location. Background thread builds an offline cache of data to support this. Map plots this cache as tower pins on the map and you can hover and active site frequencies from the map. (#2642)
Updates sdrtrunk to use latest radio reference api jar.

Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-14 09:37:29 -05:00
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
200 changed files with 23555 additions and 8338 deletions

View File

@@ -168,7 +168,7 @@ dependencies {
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
implementation 'org.apache.commons:commons-lang3:3.21.0'
implementation 'org.apache.commons:commons-math3:3.6.1'

2
mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[tools]
java = "liberica-javafx-27"

View File

@@ -82,5 +82,14 @@ dependencyResolutionManagement {
metadataSources { artifact() }
content { includeModule 'io.github.dsheirer', 'jbladerf' }
}
//radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api)
//are not pulled in transitively and are declared directly in build.gradle
ivy {
url = 'https://github.com/DSheirer/radio-reference-api/releases/download'
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
metadataSources { artifact() }
content { includeModule 'io.github.dsheirer', 'radio-reference-api' }
}
}
}

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

@@ -41,11 +41,11 @@ public abstract class AmbeAudioModule extends JmbeAudioModule
{
if(getAudioCodec() != null)
{
mLog.info("AMBE CODEC successfully loaded - P25-2/DMR/NXDN audio will be available");
mLog.info("AMBE CODEC successfully loaded - P25-2/DMR/NXDN audio can be processed");
}
else
{
mLog.warn("AMBE CODEC not loaded - P25-2/DMR/NXDN audio will NOT be available");
mLog.warn("AMBE CODEC not loaded - P25-2/DMR/NXDN audio will NOT be processed");
}
sLibraryStatusLogged = true;

View File

@@ -57,10 +57,11 @@ public class ByteNativeBuffer extends AbstractNativeBuffer
public ByteNativeBuffer(byte[] samples, long timestamp, float averageDc, float samplesPerMillisecond)
{
super(timestamp, samplesPerMillisecond);
//Ensure we're an even multiple of the fragment size. Typically, this will be 64k or 128k
if(samples.length % FRAGMENT_SIZE != 0)
//Ensure we're an even multiple of the fragment size in bytes (2 bytes per I/Q sample). Typically, this
//will be 64k or 128k
if(samples.length % (FRAGMENT_SIZE * 2) != 0)
{
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + FRAGMENT_SIZE);
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + (FRAGMENT_SIZE * 2));
}
mSamples = samples;

View File

@@ -1,137 +0,0 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2017 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.channel.details;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import net.miginfocom.swing.MigLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.text.DefaultCaret;
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class ChannelDetailPanel extends JPanel implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private JLabel mSystemLabel;
private JLabel mSiteLabel;
private JLabel mNameLabel;
private JTextArea mDetailTextPane;
private ChannelProcessingManager mChannelProcessingManager;
private ProcessingChain mProcessingChain;
public ChannelDetailPanel(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
init();
}
private void init()
{
setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[]0[grow,fill]"));
JPanel buttonPanel = new JPanel();
buttonPanel.setLayout(new MigLayout("insets 1 1 1 1", "[][grow,fill][][grow,fill][][grow,fill][]", ""));
buttonPanel.add(new JLabel("System:"));
mSystemLabel = new JLabel(" ");
buttonPanel.add(mSystemLabel);
buttonPanel.add(new JLabel("Site:"));
mSiteLabel = new JLabel(" ");
buttonPanel.add(mSiteLabel);
buttonPanel.add(new JLabel("Channel Name:"));
mNameLabel = new JLabel(" ");
buttonPanel.add(mNameLabel);
JButton refreshButton = new JButton("Refresh");
refreshButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
receive(mProcessingChain);
}
});
buttonPanel.add(refreshButton);
add(buttonPanel, "wrap");
mDetailTextPane = new JTextArea(EMPTY_DETAILS);
DefaultCaret caret = (DefaultCaret)mDetailTextPane.getCaret();
caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
add(new JScrollPane(mDetailTextPane));
}
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
final String system = channel != null ? channel.getSystem() : null;
final String site = channel != null ? channel.getSite() : null;
final String name = channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null;
final String details;
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState : processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
details = sb.toString();
}
else
{
details = EMPTY_DETAILS;
}
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
mSystemLabel.setText(system);
mSiteLabel.setText(site);
mNameLabel.setText(name);
mDetailTextPane.setText(details);
}
});
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* 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
@@ -18,67 +18,73 @@
*/
package io.github.dsheirer.channel.metadata;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.sample.Listener;
import java.awt.EventQueue;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.swing.table.AbstractTableModel;
public class ChannelMetadataModel extends AbstractTableModel implements IChannelMetadataUpdateListener
/**
* Model of the channel metadata for the currently processing channels, with a map from each channel metadata to the
* channel that it belongs to. The model is thread-safe and isn't tied to a user interface toolkit: listeners are
* notified on the thread that changes the model and pass the changes to their user interface thread.
*/
public class ChannelMetadataModel implements IChannelMetadataUpdateListener
{
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataModel.class);
private final List<ChannelMetadata> mChannelMetadata = new ArrayList<>();
private final Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap<>();
private final List<Listener> mListeners = new CopyOnWriteArrayList<>();
public static final int COLUMN_DECODER_STATE = 0;
public static final int COLUMN_DECODER_TYPE = 1;
public static final int COLUMN_USER_FROM = 2;
public static final int COLUMN_USER_FROM_ALIAS = 3;
public static final int COLUMN_USER_TO = 4;
public static final int COLUMN_USER_TO_ALIAS = 5;
public static final int COLUMN_DECODER_LOGICAL_CHANNEL_NAME = 6;
public static final int COLUMN_CONFIGURATION_FREQUENCY = 7;
public static final int COLUMN_CONFIGURATION_CHANNEL = 8;
private static final String[] COLUMNS = {"Status", "Decoder", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Channel Name"};
private List<ChannelMetadata> mChannelMetadata = new ArrayList();
private Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap();
private Listener<ChannelAndMetadata> mChannelAddListener;
public ChannelMetadataModel()
/**
* Listener for changes to the channel metadata model. Notifications arrive on the thread that changed the
* model, sometimes while the model is locked, so implementations must not block and must not call back into
* the model from the notification thread.
*/
public interface Listener
{
MyEventBus.getGlobalEventBus().register(this);
/**
* Channel metadata(s) for a channel were added to the model.
*/
void added(ChannelAndMetadata channelAndMetadata);
/**
* Channel metadata was removed from the model.
*/
void removed(ChannelMetadata channelMetadata);
/**
* A field of a channel metadata in the model was updated.
*/
void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField);
}
/**
* Receives preference update notifications via the event bus
* @param preferenceType that was updated
* Constructs an instance
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
public ChannelMetadataModel()
{
if(preferenceType == PreferenceType.TALKGROUP_FORMAT)
{
EventQueue.invokeLater(() -> {
for(int row = 0; row < mChannelMetadata.size(); row++)
{
fireTableCellUpdated(row, COLUMN_USER_FROM);
fireTableCellUpdated(row, COLUMN_USER_TO);
}
});
}
}
/**
* Registers the listener to receive model change notifications.
* @param listener to register
* @return the channel metadata in the model when the listener is registered, so that the listener starts from
* the current state and receives the changes that follow.
*/
public synchronized List<ChannelMetadata> addListener(Listener listener)
{
mListeners.add(listener);
return new ArrayList<>(mChannelMetadata);
}
/**
* Removes the listener from receiving model change notifications.
*/
public void removeListener(Listener listener)
{
mListeners.remove(listener);
}
/**
@@ -87,10 +93,16 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
*/
public String getDiagnosticInformation()
{
StringBuilder sb = new StringBuilder();
Map<ChannelMetadata,Channel> mapCopy = new HashMap<>(mMetadataChannelMap);
List<ChannelMetadata> metadataCopy = new ArrayList<>(mChannelMetadata);
Map<ChannelMetadata,Channel> mapCopy;
List<ChannelMetadata> metadataCopy;
synchronized(this)
{
mapCopy = new HashMap<>(mMetadataChannelMap);
metadataCopy = new ArrayList<>(mChannelMetadata);
}
StringBuilder sb = new StringBuilder();
sb.append("Channel Metadata Model Diagnostics\n");
for(ChannelMetadata channelMetadata: metadataCopy)
@@ -122,51 +134,34 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
return sb.toString();
}
/**
* Prepares the model for disposal.
*/
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
mListeners.clear();
}
/**
* Lookup model row for the specified channel metadata
* Adds the channel metadata(s) for a channel to the model.
*/
public int getRow(ChannelMetadata channelMetadata)
public synchronized void add(ChannelAndMetadata channelAndMetadata)
{
if(mChannelMetadata.contains(channelMetadata))
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
{
return mChannelMetadata.indexOf(channelMetadata);
}
return -1;
}
/**
* Registers the listener to be notified when channel metadata(s) are added to the model.
* @param listener to receive channel associated with the channel metadata(s) that were added
*/
public void setChannelAddListener(Listener<ChannelAndMetadata> listener)
{
mChannelAddListener = listener;
}
public void add(ChannelAndMetadata channelAndMetadata)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(() -> {
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
if(!mChannelMetadata.contains(channelMetadata))
{
mChannelMetadata.add(channelMetadata);
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
int index = mChannelMetadata.indexOf(channelMetadata);
fireTableRowsInserted(index, index);
channelMetadata.setUpdateEventListener(ChannelMetadataModel.this);
}
if(mChannelAddListener != null)
{
mChannelAddListener.receive(channelAndMetadata);
}
});
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
channelMetadata.setUpdateEventListener(this);
}
for(Listener listener: mListeners)
{
listener.added(channelAndMetadata);
}
}
/**
@@ -174,7 +169,7 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
* @param channelMetadatas
* @param channel
*/
public void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
public synchronized void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
{
for(ChannelMetadata channelMetadata: channelMetadatas)
{
@@ -182,178 +177,46 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
}
}
public void remove(ChannelMetadata channelMetadata)
/**
* Removes the channel metadata from the model.
*/
public synchronized void remove(ChannelMetadata channelMetadata)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(() -> {
channelMetadata.removeUpdateEventListener();
int index = mChannelMetadata.indexOf(channelMetadata);
mChannelMetadata.remove(channelMetadata);
mMetadataChannelMap.remove(channelMetadata);
channelMetadata.removeUpdateEventListener();
mMetadataChannelMap.remove(channelMetadata);
if(index >= 0)
if(mChannelMetadata.remove(channelMetadata))
{
for(Listener listener: mListeners)
{
fireTableRowsDeleted(index, index);
listener.removed(channelMetadata);
}
});
}
}
/**
* Get the channel metadata at the specified model row index
* Current channel metadata in the model.
* @return copy of the channel metadata list.
*/
public ChannelMetadata getChannelMetadata(int row)
public synchronized List<ChannelMetadata> getChannelMetadata()
{
if(row < mChannelMetadata.size())
{
return mChannelMetadata.get(row);
}
return null;
return new ArrayList<>(mChannelMetadata);
}
/**
* Returns the channel that matches the metadata or null
*/
public Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
public synchronized Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
{
return mMetadataChannelMap.get(channelMetadata);
}
@Override
public int getRowCount()
{
return mChannelMetadata.size();
}
@Override
public int getColumnCount()
{
return COLUMNS.length;
}
@Override
public String getColumnName(int column)
{
return COLUMNS[column];
}
@Override
public Class<?> getColumnClass(int columnIndex)
{
switch(columnIndex)
{
case COLUMN_USER_FROM:
case COLUMN_USER_TO:
return ChannelMetadata.class;
case COLUMN_USER_FROM_ALIAS:
case COLUMN_USER_TO_ALIAS:
return Alias.class;
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
return String.class;
default:
return Identifier.class;
}
}
@Override
public Object getValueAt(int rowIndex, int columnIndex)
{
if(rowIndex <= mChannelMetadata.size())
{
ChannelMetadata channelMetadata = mChannelMetadata.get(rowIndex);
switch(columnIndex)
{
case COLUMN_DECODER_STATE:
return channelMetadata.getChannelStateIdentifier();
case COLUMN_DECODER_TYPE:
return channelMetadata.getDecoderTypeConfigurationIdentifier();
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
if(channelMetadata.hasTimeslot())
{
if(id == null)
{
return "TS:" + channelMetadata.getTimeslot();
}
else
{
String value = id.getValue().replace(" TS1", "")
.replace(" TS2", "");
return value + " TS:" + channelMetadata.getTimeslot();
}
}
else
{
if(id == null)
{
return null;
}
else
{
return id.getValue();
}
}
case COLUMN_CONFIGURATION_FREQUENCY:
return channelMetadata.getFrequencyConfigurationIdentifier();
case COLUMN_CONFIGURATION_CHANNEL:
return channelMetadata.getChannelNameConfigurationIdentifier();
case COLUMN_USER_TO:
return channelMetadata;
case COLUMN_USER_FROM:
return channelMetadata;
case COLUMN_USER_FROM_ALIAS:
return channelMetadata.getFromIdentifierAliases();
case COLUMN_USER_TO_ALIAS:
return channelMetadata.getToIdentifierAliases();
}
}
return null;
}
@Override
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
{
final int rowIndex = mChannelMetadata.indexOf(channelMetadata);
if(rowIndex >= 0)
for(Listener listener: mListeners)
{
//Execute on the swing thread to avoid threading issues
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
switch(channelMetadataField)
{
case CONFIGURATION_CHANNEL:
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_CHANNEL);
break;
case CONFIGURATION_FREQUENCY:
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_FREQUENCY);
break;
case DECODER_CHANNEL_NAME:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_LOGICAL_CHANNEL_NAME);
break;
case DECODER_TYPE:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_TYPE);
break;
case DECODER_STATE:
fireTableCellUpdated(rowIndex, COLUMN_DECODER_STATE);
break;
case USER_FROM:
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM);
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM_ALIAS);
break;
case USER_TO:
fireTableCellUpdated(rowIndex, COLUMN_USER_TO);
fireTableCellUpdated(rowIndex, COLUMN_USER_TO_ALIAS);
break;
}
}
});
listener.updated(channelMetadata, channelMetadataField);
}
}
}

View File

@@ -1,494 +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.channel.metadata;
import com.google.common.base.Joiner;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.channel.state.State;
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.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
import io.github.dsheirer.sample.Broadcaster;
import io.github.dsheirer.sample.Listener;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.text.DecimalFormat;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.SwingConstants;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableCellRenderer;
public class ChannelMetadataPanel extends JPanel implements ListSelectionListener
{
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataPanel.class);
private static final String TABLE_PREFERENCE_KEY = "channel.metadata.panel";
private ChannelModel mChannelModel;
private ChannelProcessingManager mChannelProcessingManager;
private IconModel mIconModel;
private UserPreferences mUserPreferences;
private JTable mTable;
private Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
private Map<State,Color> mBackgroundColors = new EnumMap<>(State.class);
private Map<State,Color> mForegroundColors = new EnumMap<>(State.class);
private JTableColumnWidthMonitor mTableColumnMonitor;
private Channel mUserSelectedChannel;
/**
* Table view for currently decoding channel metadata
*/
public ChannelMetadataPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences)
{
mChannelModel = playlistManager.getChannelModel();
mChannelProcessingManager = playlistManager.getChannelProcessingManager();
mIconModel = iconModel;
mUserPreferences = userPreferences;
init();
}
/**
* Initializes the panel
*/
private void init()
{
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
mTable = new JTable(mChannelProcessingManager.getChannelMetadataModel());
mChannelProcessingManager.getChannelMetadataModel().setChannelAddListener(new ChannelAddListener());
DefaultTableCellRenderer renderer = (DefaultTableCellRenderer)mTable.getDefaultRenderer(String.class);
renderer.setHorizontalAlignment(SwingConstants.CENTER);
mTable.getSelectionModel().addListSelectionListener(this);
mTable.addMouseListener(new MouseSupport());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_DECODER_STATE)
.setCellRenderer(new ColoredStateCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM)
.setCellRenderer(new FromCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO)
.setCellRenderer(new ToCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM_ALIAS)
.setCellRenderer(new AliasCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO_ALIAS)
.setCellRenderer(new AliasCellRenderer());
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_CONFIGURATION_FREQUENCY)
.setCellRenderer(new FrequencyCellRenderer());
//Add a table column width monitor to store/restore column widths
mTableColumnMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
JScrollPane scrollPane = new JScrollPane(mTable, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
add(scrollPane);
setColors();
}
/**
* Setup the background and foreground color palette for the various channel states.
*/
private void setColors()
{
mBackgroundColors.put(State.ACTIVE, Color.CYAN);
mForegroundColors.put(State.ACTIVE, Color.BLUE);
mBackgroundColors.put(State.CALL, Color.BLUE);
mForegroundColors.put(State.CALL, Color.YELLOW);
mBackgroundColors.put(State.CONTROL, Color.ORANGE);
mForegroundColors.put(State.CONTROL, Color.BLUE);
mBackgroundColors.put(State.DATA, Color.GREEN);
mForegroundColors.put(State.DATA, Color.BLUE);
mBackgroundColors.put(State.ENCRYPTED, Color.MAGENTA);
mForegroundColors.put(State.ENCRYPTED, Color.WHITE);
mBackgroundColors.put(State.FADE, Color.LIGHT_GRAY);
mForegroundColors.put(State.FADE, Color.DARK_GRAY);
mBackgroundColors.put(State.IDLE, Color.WHITE);
mForegroundColors.put(State.IDLE, Color.DARK_GRAY);
mBackgroundColors.put(State.RESET, Color.PINK);
mForegroundColors.put(State.RESET, Color.YELLOW);
mBackgroundColors.put(State.TEARDOWN, Color.DARK_GRAY);
mForegroundColors.put(State.TEARDOWN, Color.WHITE);
}
@Override
public void valueChanged(ListSelectionEvent e)
{
if(!mTable.getSelectionModel().getValueIsAdjusting())
{
ProcessingChain processingChain = null;
int selectedViewRow = mTable.getSelectedRow();
if(selectedViewRow >= 0)
{
int selectedModelRow = mTable.convertRowIndexToModel(selectedViewRow);
ChannelMetadata selectedMetadata = mChannelProcessingManager.getChannelMetadataModel()
.getChannelMetadata(selectedModelRow);
if(selectedMetadata != null)
{
mUserSelectedChannel = mChannelProcessingManager.getChannelMetadataModel()
.getChannelFromMetadata(selectedMetadata);
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
}
}
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
}
}
/**
* Adds the listener to receive the processing chain associated with the metadata selected in the
* metadata table.
*/
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
{
mSelectedProcessingChainBroadcaster.addListener(listener);
}
/**
* Cell renderer for frequency values
*/
public class FrequencyCellRenderer extends DefaultTableCellRenderer
{
private final DecimalFormat FREQUENCY_FORMATTER = new DecimalFormat( "#.00000" );
public FrequencyCellRenderer()
{
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof FrequencyConfigurationIdentifier)
{
long frequency = ((FrequencyConfigurationIdentifier)value).getValue();
label.setText(FREQUENCY_FORMATTER.format(frequency / 1e6d));
}
else
{
label.setText(null);
}
return label;
}
}
/**
* Alias cell renderer
*/
public class AliasCellRenderer extends DefaultTableCellRenderer
{
public AliasCellRenderer()
{
setHorizontalAlignment(JLabel.LEFT);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof List<?>)
{
List<Alias> aliases = (List<Alias>)value;
if(!aliases.isEmpty())
{
label.setText(Joiner.on(", ").skipNulls().join(aliases));
label.setIcon(mIconModel.getIcon(aliases.get(0).getIconName(), IconModel.DEFAULT_ICON_SIZE));
label.setForeground(ThemeManager.getInstance().getReadableColor(aliases.get(0).getDisplayColor(),
label.getBackground()));
}
else
{
label.setText(null);
label.setIcon(null);
label.setForeground(table.getForeground());
}
}
else
{
label.setText(null);
label.setIcon(null);
label.setForeground(table.getForeground());
}
return label;
}
}
/**
* Abstract cell renderer for identifiers
*/
public abstract class IdentifierCellRenderer extends DefaultTableCellRenderer
{
private final static String EMPTY_VALUE = "-----";
private TalkgroupFormatPreference mTalkgroupFormatPreference;
public IdentifierCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
mTalkgroupFormatPreference = talkgroupFormatPreference;
setHorizontalAlignment(JLabel.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
if(value instanceof ChannelMetadata)
{
ChannelMetadata channelMetadata = (ChannelMetadata)value;
Identifier identifier = getIdentifier(channelMetadata);
String text = mTalkgroupFormatPreference.format(identifier);
if(text == null || text.isEmpty())
{
text = EMPTY_VALUE;
}
else if(hasAdditionalIdentifier(channelMetadata))
{
text = text + " " + getAdditionalIdentifier(channelMetadata);
}
label.setText(text);
}
else
{
label.setText(EMPTY_VALUE);
}
return label;
}
public abstract Identifier getIdentifier(ChannelMetadata channelMetadata);
public abstract boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata);
public abstract Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata);
}
/**
* Cell renderer for the FROM identifier
*/
public class FromCellRenderer extends IdentifierCellRenderer
{
public FromCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
super(talkgroupFormatPreference);
}
@Override
public Identifier getIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getFromIdentifier();
}
@Override
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getTalkerAliasIdentifier();
}
@Override
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.hasTalkerAliasIdentifier();
}
}
/**
* Cell renderer for the TO identifier
*/
public class ToCellRenderer extends IdentifierCellRenderer
{
public ToCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
{
super(talkgroupFormatPreference);
}
@Override
public Identifier getIdentifier(ChannelMetadata channelMetadata)
{
return channelMetadata.getToIdentifier();
}
@Override
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata) {return null;}
@Override
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata) {return false;}
}
public class ColoredStateCellRenderer extends DefaultTableCellRenderer
{
public ColoredStateCellRenderer()
{
setHorizontalAlignment(SwingConstants.CENTER);
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus,
int row, int column)
{
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
Color background = table.getBackground();
Color foreground = table.getForeground();
if(value instanceof ChannelStateIdentifier)
{
State state = ((ChannelStateIdentifier)value).getValue();
label.setText(state.getDisplayValue());
if(mBackgroundColors.containsKey(state))
{
background = mBackgroundColors.get(state);
}
if(mForegroundColors.containsKey(state))
{
foreground = mForegroundColors.get(state);
}
}
else
{
setText("----");
}
setBackground(background);
setForeground(foreground);
return label;
}
}
public class MouseSupport extends MouseAdapter
{
@Override
public void mouseClicked(MouseEvent e)
{
if(e.getButton() == MouseEvent.BUTTON3) //Right click for context
{
JPopupMenu popupMenu = new JPopupMenu();
boolean populated = false;
int viewRowIndex = mTable.rowAtPoint(e.getPoint());
if(viewRowIndex >= 0)
{
int modelRowIndex = mTable.convertRowIndexToModel(viewRowIndex);
if(modelRowIndex >= 0)
{
ChannelMetadata metadata = mChannelProcessingManager.getChannelMetadataModel().getChannelMetadata(modelRowIndex);
if(metadata != null)
{
Channel channel = mChannelProcessingManager.getChannelMetadataModel()
.getChannelFromMetadata(metadata);
if(channel != null)
{
JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle());
viewChannel.addActionListener(e2 -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
popupMenu.add(viewChannel);
populated = true;
}
}
}
}
if(!populated)
{
popupMenu.add(new JMenuItem("No Actions Available"));
}
popupMenu.show(mTable, e.getX(), e.getY());
}
}
}
/**
* Listener to be notified when a channel and associated channel metadata(s) are added to the underlying
* channel metadata model.
*
* When a channel is added, it is compared the the last user selected channel and if they are the same, it
* invokes a selection event on the channel metadata row so that the channel metadata is re-selected. This is
* primarily a hack to counter-act the DMR Capacity+ REST channel rotation where a channel is converted to a
* traffic channel and the previous channel is restarted. The UI effect is that the user selected channel row
* in the Now Playing window continually loses selection over the channel and causes the user to perpetually
* chase the channel row.
*/
public class ChannelAddListener implements Listener<ChannelAndMetadata>
{
@Override
public void receive(ChannelAndMetadata channelAndMetadata)
{
if(mUserSelectedChannel != null &&
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
{
List<ChannelMetadata> metadata = channelAndMetadata.getChannelMetadata();
if(metadata.size() > 0)
{
int modelRow = mChannelProcessingManager.getChannelMetadataModel().getRow(metadata.get(0));
if(modelRow >= 0)
{
int tableRow = mTable.convertRowIndexToView(modelRow);
mTable.getSelectionModel().setSelectionInterval(tableRow, tableRow);
}
}
}
}
}
}

View File

@@ -1,138 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 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.channel.metadata;
import com.jidesoft.swing.JideSplitPane;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.channel.details.ChannelDetailPanel;
import io.github.dsheirer.gui.channel.ChannelSpectrumPanel;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.module.decode.event.DecodeEventPanel;
import io.github.dsheirer.module.decode.event.MessageActivityPanel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
import net.miginfocom.swing.MigLayout;
import javax.swing.JPanel;
/**
* Swing panel for Now Playing channels table and channel details tab set.
*/
public class NowPlayingPanel extends JPanel
{
private final ChannelMetadataPanel mChannelMetadataPanel;
private final ChannelDetailPanel mChannelDetailPanel;
private final DecodeEventPanel mDecodeEventPanel;
private final MessageActivityPanel mMessageActivityPanel;
private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel;
private JideTabbedPane mTabbedPane;
private JideSplitPane mSplitPane;
private boolean mDetailTabsVisible;
/**
* GUI panel that combines the currently decoding channels metadata table and viewers for channel details,
* messages, events, and spectral view.
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
SettingsManager settingsManager, boolean detailTabsVisible)
{
mChannelDetailPanel = new ChannelDetailPanel(playlistManager.getChannelProcessingManager());
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
mDetailTabsVisible = detailTabsVisible;
init();
}
/**
* Change the visibility of the channel details tabs panel.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
//Only adjust if there is a change in state
if(visible ^ mDetailTabsVisible)
{
mDetailTabsVisible = visible;
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
else
{
getSplitPane().remove(getTabbedPane());
}
revalidate();
}
}
private JideTabbedPane getTabbedPane()
{
if(mTabbedPane == null)
{
mTabbedPane = new JideTabbedPane();
mTabbedPane.addTab("Details", mChannelDetailPanel);
mTabbedPane.addTab("Events", mDecodeEventPanel);
mTabbedPane.addTab("Messages", mMessageActivityPanel);
mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
mTabbedPane.setFont(this.getFont());
//Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
.indexOfComponent(mChannelSpectrumSquelchPanel)));
}
return mTabbedPane;
}
/**
* Split pane for channels table and channel details tabs.
*/
private JideSplitPane getSplitPane()
{
if(mSplitPane == null)
{
mSplitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT);
mSplitPane.setShowGripper(true);
}
return mSplitPane;
}
private void init()
{
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
getSplitPane().add(mChannelMetadataPanel);
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
add(getSplitPane());
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel);
}
}

View File

@@ -21,10 +21,11 @@ package io.github.dsheirer.controller;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.audio.playback.AudioPanel;
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
import io.github.dsheirer.channel.metadata.NowPlayingPanel;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.nowplaying.NowPlayingPanel;
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.map.MapPanel;
import io.github.dsheirer.map.MapService;
import io.github.dsheirer.playlist.PlaylistManager;
@@ -62,8 +63,10 @@ public class ControllerPanel extends JPanel
{
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
playlistManager.getAliasModel());
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager);
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, detailTabsVisible);
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
new RadioReferencePlaylistActions(playlistManager));
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
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

@@ -1,51 +0,0 @@
package io.github.dsheirer.dsp.gain;
public class NonClippingGain
{
private float mGain;
private float mMaxValue;
/**
* Applies a fixed gain value to the float samples.
*
* @param gain value
* @param maxValue max absolute value to prevent clipping ( 0.0 to 1.0 )
*/
public NonClippingGain( float gain, float maxValue )
{
mGain = gain;
mMaxValue = maxValue;
}
/**
* Applies gain to the sample
*/
public float apply( float sample )
{
float adjusted = sample * mGain;
if( adjusted > mMaxValue )
{
adjusted = mMaxValue;
}
if( adjusted < -mMaxValue )
{
adjusted = -mMaxValue;
}
return adjusted;
}
/**
* Applies gain to the sample array
*/
public float[] apply( float[] samples )
{
for( int x = 0; x < samples.length; x++ )
{
samples[ x ] = apply( samples[ x ]);
}
return samples;
}
}

View File

@@ -37,7 +37,8 @@ public class Hamming13
*
* @param message containing a Hamming protected word
* @param offset to the start of the protected word
* @return message index for an error bit or -1 if no errors are detected.
* @return message index for an error bit, -1 if no errors are detected, or 1000 if more than one bit error is
* detected.
*/
public static int getErrorIndex(BinaryMessage message, int offset)
{
@@ -45,7 +46,8 @@ public class Hamming13
if(syndrome > 0)
{
return offset + ERROR_INDEX[syndrome];
int errorIndex = ERROR_INDEX[syndrome];
return errorIndex >= 0 ? offset + errorIndex : IHamming.MULTIPLE_ERRORS;
}
return IHamming.NO_ERRORS;

View File

@@ -47,13 +47,6 @@ public class Hamming16 implements IHamming
return NO_ERRORS;
}
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
//flag it as invalid for multiple errors.
if(syndrome == 1 && message.getSubMessage(offset, offset + 16).cardinality() % 2 == 1) //check final parity bit
{
return MULTIPLE_ERRORS;
}
for(int index = 0; index < CHECKSUMS.length; index++)
{
if(syndrome == CHECKSUMS[index])

View File

@@ -46,13 +46,6 @@ public class Hamming17 implements IHamming
return NO_ERRORS;
}
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
//flag it as invalid for multiple errors.
if(syndrome == 1 && message.getSubMessage(offset, offset + 17).cardinality() % 2 == 1) //check final parity bit
{
return MULTIPLE_ERRORS;
}
for(int index = 0; index < CHECKSUMS.length; index++)
{
if(syndrome == CHECKSUMS[index])

View File

@@ -0,0 +1,421 @@
/*
* *****************************************************************************
* 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.edac;
import io.github.dsheirer.bits.BinaryMessage;
import java.util.function.ToIntFunction;
/**
* Hard decision minimum distance decoder for binary product codes, where each row is a codeword of a short row code
* and each column is a codeword of a systematic column code with the column parity bits in the final rows.
*
* Finds the minimum weight error pattern that produces the received row and column syndromes, using an iterative
* deepening search over the error patterns (coset) of each information row syndrome. The column parity row errors
* are fully determined by the column syndromes and the information row error choices, and the column parity rows then
* also match their row syndromes, so only the information rows are searched.
*
* Corrects every error pattern up to half the product code minimum distance, and most patterns up to the maximum
* error count. When more than one error pattern has the minimum weight, the received word is equidistant from more
* than one codeword and is treated as uncorrectable.
*
* Row words are integers where bit i (LSB = 0) holds the bit in column i. Instances are thread safe.
*/
public class ProductCodeDecoder
{
public static final int UNCORRECTABLE = -1;
//Bounds the decode time for pathological inputs (~20 ms). Error injection testing with random error patterns of
//each weight: the largest search was 630k nodes (3 ms) for BPTC(196,96) with a maximum error count of 12.
private static final long MAXIMUM_SEARCH_NODES = 4_000_000;
private final int mColumnCount;
private final int mRowCount;
private final int mInformationRowCount;
private final int[] mRowChecks;
private final int[] mColumnChecks;
private final int mColumnCheckBits;
private final long mColumnGroupMask;
private final int mMaximumErrors;
private final int[][] mCosets;
private final int[] mMinimumWeights;
/**
* Constructs an instance.
* @param rowChecks parity check matrix column (syndrome of a single bit error) for each row code bit position, where
* the array length is the column count (maximum 20).
* @param columnChecks parity check matrix column for each column code bit position, where the array length is the
* row count. The column code must be systematic, with distinct single bit parity checks for the final (parity)
* rows. For a single (even) parity check column code, each value is 1.
* @param maximumErrors maximum number of bit errors to correct.
*/
public ProductCodeDecoder(int[] rowChecks, int[] columnChecks, int maximumErrors)
{
mColumnCount = rowChecks.length;
mRowCount = columnChecks.length;
mRowChecks = rowChecks.clone();
mColumnChecks = columnChecks.clone();
mMaximumErrors = maximumErrors;
mColumnCheckBits = bitLength(mColumnChecks);
mInformationRowCount = mRowCount - mColumnCheckBits;
if(mColumnCount < 1 || mColumnCount > 20)
{
throw new IllegalArgumentException("Column count must be in range 1 - 20");
}
if(mColumnCount * mColumnCheckBits > Long.SIZE || mInformationRowCount < 1)
{
throw new IllegalArgumentException("Unsupported column code dimensions");
}
//Verify the final rows are the systematic parity rows
int parityChecks = 0;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
int check = mColumnChecks[row];
if(Integer.bitCount(check) != 1 || (parityChecks & check) != 0)
{
throw new IllegalArgumentException("Column code parity rows must have distinct single bit checks");
}
parityChecks |= check;
}
long groupMask = 0;
for(int column = 0; column < mColumnCount; column++)
{
groupMask |= 1L << (column * mColumnCheckBits);
}
mColumnGroupMask = groupMask;
//Coset lists: the error patterns for each row syndrome, up to the maximum error count, ordered by weight
int syndromeCount = 1 << bitLength(mRowChecks);
int maximumWeight = Math.min(maximumErrors, mColumnCount);
int wordCount = 1 << mColumnCount;
int[] syndromes = new int[wordCount];
int[] sizes = new int[syndromeCount];
for(int word = 0; word < wordCount; word++)
{
syndromes[word] = getSyndrome(word, mRowChecks);
if(Integer.bitCount(word) <= maximumWeight)
{
sizes[syndromes[word]]++;
}
}
mCosets = new int[syndromeCount][];
mMinimumWeights = new int[syndromeCount];
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
{
mCosets[syndrome] = new int[sizes[syndrome]];
}
int[] filled = new int[syndromeCount];
for(int weight = 0; weight <= maximumWeight; weight++)
{
for(int word = 0; word < wordCount; word++)
{
if(Integer.bitCount(word) == weight)
{
int syndrome = syndromes[word];
mCosets[syndrome][filled[syndrome]++] = word;
}
}
}
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
{
mMinimumWeights[syndrome] = mCosets[syndrome].length > 0 ? Integer.bitCount(mCosets[syndrome][0]) :
maximumWeight + 1;
}
}
/**
* Number of bits needed to represent the largest value.
*/
private static int bitLength(int[] values)
{
int combined = 0;
for(int value: values)
{
combined |= value;
}
return Integer.SIZE - Integer.numberOfLeadingZeros(combined);
}
/**
* Calculates the parity check matrix columns for a code from a syndrome calculator, as the syndrome of each single
* bit error.
* @param length of the code in bits
* @param syndrome calculator for a binary message of the code length.
* @return parity check matrix column for each bit position.
*/
public static int[] getParityChecks(int length, ToIntFunction<BinaryMessage> syndrome)
{
int[] checks = new int[length];
for(int i = 0; i < length; i++)
{
BinaryMessage message = new BinaryMessage(length);
message.set(i);
checks[i] = syndrome.applyAsInt(message);
}
return checks;
}
/**
* Calculates the syndrome of the word.
* @param word where bit i (LSB = 0) is code bit position i
* @param checks parity check matrix column for each bit position
* @return syndrome, zero for a valid codeword.
*/
public static int getSyndrome(int word, int[] checks)
{
int syndrome = 0;
while(word != 0)
{
syndrome ^= checks[Integer.numberOfTrailingZeros(word)];
word &= word - 1;
}
return syndrome;
}
/**
* Indicates if all rows and columns are valid codewords.
* @param rows to check
* @return true if valid.
*/
public boolean isValid(int[] rows)
{
long columnSyndromes = 0;
for(int row = 0; row < mRowCount; row++)
{
if(getSyndrome(rows[row], mRowChecks) != 0)
{
return false;
}
columnSyndromes ^= getColumnContribution(row, rows[row]);
}
return columnSyndromes == 0;
}
/**
* Corrects the rows to the nearest valid product codeword when the number of bit errors doesn't exceed the
* maximum error count and the nearest codeword is unique.
* @param rows to correct, where bit i of each row is the bit in column i. Unchanged when uncorrectable.
* @return number of corrected bit errors, or UNCORRECTABLE.
*/
public int decode(int[] rows)
{
if(rows.length != mRowCount)
{
throw new IllegalArgumentException("Expected " + mRowCount + " rows");
}
Search search = new Search();
boolean rowErrors = false;
for(int row = 0; row < mRowCount; row++)
{
search.mRowSyndromes[row] = getSyndrome(rows[row], mRowChecks);
rowErrors |= search.mRowSyndromes[row] != 0;
search.mColumnSyndromes ^= getColumnContribution(row, rows[row]);
}
if(!rowErrors && search.mColumnSyndromes == 0)
{
return 0;
}
//Lower bound on the error count of the remaining rows, including the column parity rows, at each search depth
int lowerBound = 0;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
lowerBound += mMinimumWeights[search.mRowSyndromes[row]];
}
search.mLowerBounds[mInformationRowCount] = lowerBound;
for(int row = mInformationRowCount - 1; row >= 0; row--)
{
search.mLowerBounds[row] = search.mLowerBounds[row + 1] + mMinimumWeights[search.mRowSyndromes[row]];
}
int start = Math.max(search.mLowerBounds[0], getErrorColumnCount(search.mColumnSyndromes));
for(search.mBudget = start; search.mBudget <= mMaximumErrors; search.mBudget++)
{
search(search, 0, 0, 0L);
if(search.mAborted || search.mSolutionCount > 1)
{
return UNCORRECTABLE;
}
if(search.mSolutionCount == 1)
{
for(int row = 0; row < mInformationRowCount; row++)
{
rows[row] ^= search.mSolution[row];
}
long residual = search.mColumnSyndromes ^ search.mSolutionContribution;
for(int row = mInformationRowCount; row < mRowCount; row++)
{
int check = mColumnChecks[row];
for(int column = 0; column < mColumnCount; column++)
{
if(((residual >>> (column * mColumnCheckBits)) & check) != 0)
{
rows[row] ^= 1 << column;
}
}
}
return search.mBudget;
}
}
return UNCORRECTABLE;
}
/**
* Depth first search over the information row error patterns, for solutions that don't exceed the error budget.
* @param search state
* @param row to choose an error pattern for
* @param used error count of the error patterns chosen for the preceding rows
* @param contribution to the column syndromes from the error patterns chosen for the preceding rows
*/
private void search(Search search, int row, int used, long contribution)
{
if(++search.mNodes > MAXIMUM_SEARCH_NODES)
{
search.mAborted = true;
return;
}
//The remaining column syndromes must be resolved by the remaining rows
long residual = search.mColumnSyndromes ^ contribution;
if(row == mInformationRowCount)
{
//Each remaining column syndrome bit is a column parity row error
if(used + Long.bitCount(residual) <= search.mBudget && ++search.mSolutionCount == 1)
{
System.arraycopy(search.mChosen, 0, search.mSolution, 0, mInformationRowCount);
search.mSolutionContribution = contribution;
}
return;
}
if(used + Math.max(search.mLowerBounds[row], getErrorColumnCount(residual)) > search.mBudget)
{
return;
}
int remainingLowerBound = search.mLowerBounds[row + 1];
for(int error: mCosets[search.mRowSyndromes[row]])
{
int weight = Integer.bitCount(error);
if(used + weight + remainingLowerBound > search.mBudget)
{
break; //Error patterns are ordered by weight
}
search.mChosen[row] = error;
search(search, row + 1, used + weight, contribution ^ getColumnContribution(row, error));
if(search.mAborted || search.mSolutionCount > 1)
{
return;
}
}
}
/**
* Calculates the contribution of a row word to the column syndromes, where the syndrome for each column occupies
* a group of column check bits, starting with column 0 in the LSBs.
*/
private long getColumnContribution(int row, int word)
{
long contribution = 0;
long check = mColumnChecks[row];
while(word != 0)
{
contribution ^= check << (Integer.numberOfTrailingZeros(word) * mColumnCheckBits);
word &= word - 1;
}
return contribution;
}
/**
* Number of columns with a non-zero syndrome, where each requires at least one more bit error.
*/
private int getErrorColumnCount(long columnSyndromes)
{
long folded = columnSyndromes;
for(int shift = 1; shift < mColumnCheckBits; shift++)
{
folded |= columnSyndromes >>> shift;
}
return Long.bitCount(folded & mColumnGroupMask);
}
/**
* Search state for a single decode.
*/
private final class Search
{
private final int[] mRowSyndromes = new int[mRowCount];
private final int[] mLowerBounds = new int[mInformationRowCount + 1];
private final int[] mChosen = new int[mInformationRowCount];
private final int[] mSolution = new int[mInformationRowCount];
private long mColumnSyndromes;
private long mSolutionContribution;
private int mBudget;
private int mSolutionCount;
private long mNodes;
private boolean mAborted;
}
}

View File

@@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.record.AudioRecordingManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.MapViewSetting;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
@@ -59,7 +60,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;
@@ -184,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
mSettingsManager = new SettingsManager();
//Older versions stored the map view in the settings file. Move it to the map preferences once.
MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView();
if(legacyMapView != null)
{
mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(),
legacyMapView.getLongitude(), legacyMapView.getZoom());
}
AliasModel aliasModel = new AliasModel();
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
@@ -232,10 +242,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 +653,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 +829,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

@@ -1,546 +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.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.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.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;
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.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
*/
public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingChain>
{
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 ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
private SpectrumPanel mSpectrumPanel;
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
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();
private final JFXPanel mNoiseSquelchPanel;
private final JFXPanel mSymbolPanel;
private JSplitPane mSplitPane;
private FeedbackDecoder mFeedbackDecoder;
/**
* Constructs an instance.
*/
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
{
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);
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)
{
Source source = mProcessingChain.getSource();
if(source instanceof PolyphaseChannelSource pcs)
{
List<Integer> 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");
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
mSpectrumPanel = new SpectrumPanel(settingsManager);
mSpectrumPanel.setSampleSize(18.0);
/**
* 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);
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);
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);
mSampleStreamTapModule.setListener(mComplexDftProcessor);
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
mComplexDftProcessor.addConverter(DFTResultsConverter);
DFTResultsConverter.addListener(mSpectrumPanel);
mSpectrumPanel.clearSpectrum();
}
/**
* 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 DFT processor. 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()
{
if(mPanelVisible && mProcessingChain != null)
{
startDftProcessing();
}
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;
}
}
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);
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
mFrequencyOverlayPanel.setChannelBandwidth(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();
if(source instanceof TunerChannelSource tcs)
{
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
}
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
}
}
}
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<SourceEvent>
{
@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.
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
});
}
}
//TODO: is this still needed?
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

@@ -0,0 +1,309 @@
/*
* *****************************************************************************
* 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.channel;
import io.github.dsheirer.controller.channel.Channel;
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.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.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Display for channel spectrum, frequency corrections and the decoder squelch, power or symbol view.
*
* Use this class on the JavaFX application thread.
*/
public class ChannelSpectrumView extends SplitPane implements Listener<ProcessingChain>
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumView.class);
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 static final double DIVIDER_POSITION = 0.4;
private final PlaylistManager mPlaylistManager;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final ChannelSpectralDisplay mChannelSpectralDisplay = new ChannelSpectralDisplay();
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final Label mTunerPPMLabel = new Label(LABEL_PREFIX_PPM);
private final Label mTunerCorrectionLabel = new Label(LABEL_PREFIX_TUNER);
private final Label mChannelCorrectionLabel = new Label(LABEL_PREFIX_CHANNEL);
private final Label mDecoderCorrectionLabel = new Label(LABEL_PREFIX_DECODER);
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
private ProcessingChain mProcessingChain;
private boolean mPanelVisible = false;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channel configurations
*/
public ChannelSpectrumView(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
mSignalPowerView = new SignalPowerView(mPlaylistManager);
URL resource = getClass().getResource("/sdrtrunk_style.css");
if(resource != null)
{
mSymbolView.getStylesheets().add(resource.toExternalForm());
}
else
{
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
}
//Use the same color theme as the channel spectral display
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox labels = new HBox(10, leftSpacer, mTunerPPMLabel, new Label("Corrections (Hz) "), mTunerCorrectionLabel,
mChannelCorrectionLabel, mDecoderCorrectionLabel, rightSpacer);
labels.setAlignment(Pos.CENTER);
labels.setPadding(new Insets(2));
VBox.setVgrow(mChannelSpectralDisplay, Priority.ALWAYS);
VBox spectrumBox = new VBox(labels, mChannelSpectralDisplay);
spectrumBox.setMinWidth(0);
getItems().addAll(spectrumBox, mNoiseSquelchView);
reset();
}
/**
* Pads the value to a fixed width and returns the prefix and padded value.
*/
private static String getPaddedLabel(String prefix, Number value)
{
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
}
/**
* Signals this view to indicate if it is visible, to turn on the DFT processor when the view is visible and turn
* it off when it's not.
* @param visible true to indicate that this view is showing/visible.
*/
public void setPanelVisible(boolean visible)
{
mPanelVisible = visible;
updateFFTProcessing();
mNoiseSquelchView.setShowing(visible);
mSymbolView.setShowing(visible);
}
/**
* Turns on DFT processing when there is a processing chain and the user has this view selected and visible.
* Otherwise, turns off DFT processing.
*/
private void updateFFTProcessing()
{
boolean active = mPanelVisible && mProcessingChain != null;
if(active)
{
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
}
else
{
mSampleStreamTapModule.removeListener();
}
mChannelSpectralDisplay.setActive(active);
}
/**
* Resets controls when changing processing chain source.
*/
private void reset()
{
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
mTunerPPMLabel.setDisable(true);
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setDisable(true);
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setDisable(true);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setDisable(true);
mChannelSpectralDisplay.setChannel(0, 0, 0);
}
/**
* Shows the processing chain details. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@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);
}
reset();
mProcessingChain = processingChain;
if(mProcessingChain != null)
{
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
{
setRightComponent(mNoiseSquelchView);
mNoiseSquelchView.setController(nbfmDecoder);
}
else if(primaryDecoder instanceof AMDecoder)
{
setRightComponent(mSignalPowerView);
mSignalPowerView.setProcessingChain(mProcessingChain);
}
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
{
setRightComponent(mSymbolView);
mSymbolView.setSymbolProvider(feedbackDecoder);
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
}
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
int bandwidth = 0;
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
bandwidth = tunerChannels.getFirst().getBandwidth();
}
}
if(source instanceof TunerChannelSource tcs)
{
mChannelSpectralDisplay.setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
}
}
updateFFTProcessing();
}
/**
* Shows the node on the right side of the split pane.
*/
private void setRightComponent(Node node)
{
if(getItems().size() < 2 || getItems().get(1) != node)
{
if(getItems().size() > 1)
{
getItems().remove(1);
}
getItems().add(node);
setDividerPositions(DIVIDER_POSITION);
}
}
/**
* Processor for source event stream to capture power level and squelch related source events.
*/
private class SourceEventProcessor implements Listener<SourceEvent>
{
@Override
public void receive(SourceEvent sourceEvent)
{
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS &&
sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
{
Platform.runLater(() -> {
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
mTunerPPMLabel.setDisable(false);
if(status.isAutoPPM())
{
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
}
else
{
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
}
mTunerCorrectionLabel.setDisable(false);
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
mChannelCorrectionLabel.setDisable(false);
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
mDecoderCorrectionLabel.setDisable(false);
});
//The offset cursor line is negative so that it aligns with where the signal is located instead of the
//correction that's needed to bring the signal to zero.
mChannelSpectralDisplay.setCarrierOffset(-status.getDecoderCorrection());
}
mSignalPowerView.receive(sourceEvent);
}
}
}

View File

@@ -1,349 +0,0 @@
package io.github.dsheirer.gui.control;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Power meter for displaying signal power levels in dB scale with optional peak value and squelch threshold lines.
*/
public class DbPowerMeter extends JComponent
{
public static final double DEFAULT_MINIMUM_POWER = -110.0d;
public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
private static final int BAR_WIDTH = 30;
private static final int PADDING = 3;
private static final int DOUBLE_PADDING = PADDING * 2;
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final Color COLOR_BAR = Color.LIGHT_GRAY;
private static final Color COLOR_THRESHOLD = Color.BLUE;
private static final Color COLOR_PEAK = Color.PINK;
private double mMinimumValue = DEFAULT_MINIMUM_POWER;
private double mMaximumValue = DEFAULT_MAXIMUM_POWER;
private double mExtent = mMaximumValue - mMinimumValue;
private double mPeak = mMinimumValue;
private double mPower = mMinimumValue;
private double mSquelchThreshold = mMinimumValue;
private boolean mPeakVisible = false;
private boolean mSquelchThresholdVisible = false;
/**
* Constructs an instance.
*/
public DbPowerMeter()
{
setPreferredSize(new Dimension(90, 100));
setBorder(BorderFactory.createTitledBorder("Power (dB)"));
}
/**
* Resets peak, power and squelch to minimums
*/
public void reset()
{
mPeak = mMinimumValue;
mPower = mMinimumValue;
mSquelchThreshold = mMinimumValue;
repaint();
}
/**
* Primary method for rendering the control
*/
@Override
protected void paintComponent(Graphics g)
{
super.paintComponent(g);
//Draw meter border
g.setColor(getForeground());
g.drawRect(getInsets().left, getInsets().top, BAR_WIDTH, getHeight() - getInsets().top - getInsets().bottom);
//Draw filled bar
g.setColor(COLOR_BAR);
int fillHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (fillHeight * getPowerPercent() - PADDING);
int top = (fillHeight - height) + getInsets().top + PADDING;
g.fillRect(getInsets().left + PADDING, top, BAR_WIDTH - DOUBLE_PADDING, height);
//Draw threshold
if(isSquelchThresholdVisible())
{
g.setColor(COLOR_THRESHOLD);
drawPercentLine(g, getSquelchThresholdPercent());
}
//Draw peak line
if(isPeakVisible())
{
g.setColor(COLOR_PEAK);
drawPercentLine(g, getPeakPercent());
}
//Draw legend/scale
g.setColor(getForeground());
for(double legendValue: getScaleValues())
{
drawScaleText(g, legendValue);
}
}
/**
* Draws a line as a percentage of the value range (extent) using the current graphics color.
*/
private void drawPercentLine(Graphics g, double percentValue)
{
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (totalHeight * percentValue - PADDING);
int top = (totalHeight - height) + getInsets().top + PADDING;
int left = getInsets().left;
g.drawLine(left, top, left + BAR_WIDTH, top);
}
/**
* Draws a tick and text for the value
* @param g graphics object
* @param value to draw
*/
private void drawScaleText(Graphics g, double value)
{
double percent = getPercent(value);
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
int height = (int) (totalHeight * percent - PADDING);
int top = (totalHeight - height) + getInsets().top + PADDING;
int left = getInsets().left + BAR_WIDTH - PADDING;
int textOffset = getFontMetrics(getFont()).getMaxAscent() / 2 - 2;
String label = DECIMAL_FORMAT.format(value);
g.drawString(label, left + DOUBLE_PADDING, top + textOffset);
}
/**
* Scale values to display, dynamically adapted to the height of the control
*/
private List<Double> getScaleValues()
{
double ascent = getFontMetrics(getFont()).getMaxAscent() * 2;
double labelCount = getHeight() / ascent;
double interval = getExtent() / labelCount;
List<Double> values = new ArrayList<>();
for(double x = 0; x > getMinimumValue(); x -= interval)
{
values.add(x);
}
return values;
}
/**
* Current power level
* @return power level (dB)
*/
public double getPower()
{
return mPower;
}
/**
* Sets the current power level constrained to the minimum and maximum values for this control.
* @param power (dB)
*/
public void setPower(double power)
{
if(power > getMaximumValue())
{
mPower = getMaximumValue();
}
else if(power < getMinimumValue())
{
mPower = getMinimumValue();
}
else
{
mPower = power;
}
repaint();
}
/**
* Squelch threshold value.
* @return threshold (dB)
*/
public double getSquelchThreshold()
{
return mSquelchThreshold;
}
/**
* Sets the squelch thresold value
* @param squelchThreshold (dB)
*/
public void setSquelchThreshold(double squelchThreshold)
{
mSquelchThreshold = squelchThreshold;
repaint();
}
/**
* Minimum displayable power level
* @return minimum (dB) - defaults to -110 dB
*/
public double getMinimumValue()
{
return mMinimumValue;
}
/**
* Sets the minimum displayable power level
* @param minimumValue (dB)
*/
public void setMinimumValue(double minimumValue)
{
mMinimumValue = minimumValue;
updateExtent();
repaint();
}
/**
* Maximum displayable power level
* @return maximum (dB) - defaults to 0 dB
*/
public double getMaximumValue()
{
return mMaximumValue;
}
/**
* Sets the maximum displayable power level
* @param maximumValue (dB)
*/
public void setMaximumValue(double maximumValue)
{
mMaximumValue = maximumValue;
updateExtent();
repaint();
}
/**
* Updates the min/max extent
*/
private void updateExtent()
{
mExtent = getMaximumValue() - getMinimumValue();
}
/**
* Extent or range of displayable values.
* @return extent (dB)
*/
private double getExtent()
{
return mExtent;
}
/**
* Peak value line
* @return peak (dB)
*/
public double getPeak()
{
return mPeak;
}
/**
* Sets the peak value line
* @param peak (db)
*/
public void setPeak(double peak)
{
mPeak = peak;
repaint();
}
/**
* Indicates if the peak line is set to visible and the current peak value is within the min/max range.
*/
public boolean isPeakVisible()
{
return mPeakVisible & isValidValue(getPeak());
}
/**
* Sets the peak line visibility
* @param peakVisible value
*/
public void setPeakVisible(boolean peakVisible)
{
mPeakVisible = peakVisible;
repaint();
}
/**
* Indicates if the squelch threshold line is set to visible and the current squelch threshold value is
* within the min/max displayable value range.
* @return true if visible.
*/
public boolean isSquelchThresholdVisible()
{
return mSquelchThresholdVisible & isValidValue(getSquelchThreshold());
}
/**
* Sets the visibility of the squelch threshold line
* @param squelchThresholdVisible visibility
*/
public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
{
mSquelchThresholdVisible = squelchThresholdVisible;
repaint();
}
/**
* Calculates the percentage of the min/max extent for the value
* @param value to calculate
* @return percentage of displayable value range
*/
private double getPercent(double value)
{
return (value - getMinimumValue()) / getExtent();
}
/**
* Power as percentage of displayable range
*/
private double getPowerPercent()
{
return getPercent(mPower);
}
/**
* Squelch threshold as percentage of displayable range
*/
private double getSquelchThresholdPercent()
{
return getPercent(mSquelchThreshold);
}
/**
* Peak as percentage of displayable range
*/
private double getPeakPercent()
{
return getPercent(mPeak);
}
/**
* Indicates if the value is valid, meaning it falls within the min/max range of displayable values.
*/
private boolean isValidValue(double value)
{
return getMinimumValue() <= value && value <= getMaximumValue();
}
}

View File

@@ -0,0 +1,64 @@
/*
* *****************************************************************************
* 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.nowplaying;
import com.google.common.base.Joiner;
import io.github.dsheirer.alias.Alias;
import java.util.List;
/**
* Alias table cell value: the alias names to show, with the color and icon of the alias that styles the cell.
* @param text of the alias names
* @param color of the styling alias as a 32-bit RGB integer
* @param iconName of the styling alias, or null
*/
public record AliasDisplay(String text, int color, String iconName) implements Comparable<AliasDisplay>
{
/**
* Creates the display for the aliases, styled by the first alias.
* @param aliases to display
* @return display or null when there are no aliases.
*/
public static AliasDisplay of(List<Alias> aliases)
{
if(aliases == null || aliases.isEmpty())
{
return null;
}
return of(Joiner.on(", ").skipNulls().join(aliases), aliases.getFirst());
}
/**
* Creates the display for the text, styled by the alias
* @param text to display
* @param alias to style the display
* @return display
*/
public static AliasDisplay of(String text, Alias alias)
{
return new AliasDisplay(text, alias.getColor(), alias.getIconName());
}
@Override
public int compareTo(AliasDisplay other)
{
return String.CASE_INSENSITIVE_ORDER.compare(text, other.text);
}
}

View File

@@ -0,0 +1,120 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Activity summary details for the selected channel.
*/
public class ChannelDetailView extends VBox implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private final ChannelProcessingManager mChannelProcessingManager;
private final Label mSystemLabel = new Label();
private final Label mSiteLabel = new Label();
private final Label mNameLabel = new Label();
private final TextArea mDetailTextArea = new TextArea(EMPTY_DETAILS);
private ProcessingChain mProcessingChain;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param channelProcessingManager for the channel of the processing chain
*/
public ChannelDetailView(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
Button refreshButton = new Button("Refresh");
refreshButton.setTooltip(new Tooltip("Update the channel details"));
refreshButton.setOnAction(event -> refresh());
HBox labels = new HBox(5, new Label("System:"), grow(mSystemLabel), new Label("Site:"), grow(mSiteLabel),
new Label("Channel Name:"), grow(mNameLabel), refreshButton);
labels.setAlignment(Pos.CENTER_LEFT);
labels.setPadding(new Insets(3, 2, 3, 2));
mDetailTextArea.setEditable(false);
VBox.setVgrow(mDetailTextArea, Priority.ALWAYS);
getChildren().addAll(labels, mDetailTextArea);
}
/**
* Updates the details for the currently selected channel. Call on the JavaFX application thread.
*/
public void refresh()
{
receive(mProcessingChain);
}
private static Label grow(Label label)
{
label.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(label, Priority.ALWAYS);
return label;
}
/**
* Shows the details for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
mSystemLabel.setText(channel != null ? channel.getSystem() : null);
mSiteLabel.setText(channel != null ? channel.getSite() : null);
mNameLabel.setText(channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null);
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState: processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
//Keep the scroll position, so that a refresh doesn't move the user back to the top
double scrollTop = mDetailTextArea.getScrollTop();
mDetailTextArea.setText(sb.toString());
mDetailTextArea.setScrollTop(scrollTop);
}
else
{
mDetailTextArea.setText(EMPTY_DETAILS);
}
}
}

View File

@@ -0,0 +1,463 @@
/*
* *****************************************************************************
* 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.nowplaying;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.metadata.ChannelAndMetadata;
import io.github.dsheirer.channel.metadata.ChannelMetadata;
import io.github.dsheirer.channel.metadata.ChannelMetadataField;
import io.github.dsheirer.channel.metadata.ChannelMetadataModel;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.controller.channel.Channel;
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.table.ColumnConfigurationButton;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
import io.github.dsheirer.sample.Broadcaster;
import io.github.dsheirer.sample.Listener;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Table of the channel metadata for the currently processing channels. Selecting a row broadcasts the processing
* chain for the channel to the registered listeners.
*/
public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Listener
{
private static final String TABLE_KEY = "now.playing.channels";
private static final String SWING_TABLE_KEY = "channel.metadata.panel";
private static final String EMPTY_IDENTIFIER = "-----";
private static final String EMPTY_STATE = "----";
private static final Map<State,String> STATE_STYLES = new EnumMap<>(State.class);
static
{
//Colors match the Swing version of the table (java.awt.Color constants)
STATE_STYLES.put(State.ACTIVE, style("#00FFFF", "#0000FF"));
STATE_STYLES.put(State.CALL, style("#0000FF", "#FFFF00"));
STATE_STYLES.put(State.CONTROL, style("#FFC800", "#0000FF"));
STATE_STYLES.put(State.DATA, style("#00FF00", "#0000FF"));
STATE_STYLES.put(State.ENCRYPTED, style("#FF00FF", "#FFFFFF"));
STATE_STYLES.put(State.FADE, style("#C0C0C0", "#404040"));
STATE_STYLES.put(State.IDLE, style("#FFFFFF", "#404040"));
STATE_STYLES.put(State.RESET, style("#FFAFAF", "#FFFF00"));
STATE_STYLES.put(State.TEARDOWN, style("#404040", "#FFFFFF"));
}
private final ChannelProcessingManager mChannelProcessingManager;
private final ChannelMetadataModel mChannelMetadataModel;
private final IconModel mIconModel;
private final TalkgroupFormatPreference mTalkgroupFormatPreference;
private final TableView<Row> mTableView = new TableView<>();
private final ObservableList<Row> mRows = FXCollections.observableArrayList();
//Changed on the JavaFX thread and read on the decoder threads that send channel metadata updates
private final Map<ChannelMetadata,Row> mRowMap = new ConcurrentHashMap<>();
private final Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
private final TableColumnMonitor<Row> mTableColumnMonitor;
private Channel mUserSelectedChannel;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param channelProcessingManager for the channel metadata and processing chains
* @param iconModel for alias icons
* @param userPreferences for talkgroup formatting and column settings
*/
public ChannelMetadataView(ChannelProcessingManager channelProcessingManager, IconModel iconModel,
UserPreferences userPreferences)
{
mChannelProcessingManager = channelProcessingManager;
mChannelMetadataModel = mChannelProcessingManager.getChannelMetadataModel();
mIconModel = iconModel;
mTalkgroupFormatPreference = userPreferences.getTalkgroupFormatPreference();
mTableView.getColumns().addAll(createColumns());
mTableView.setItems(mRows);
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
mTableView.setPlaceholder(new javafx.scene.control.Label("No channels are processing"));
mTableView.setRowFactory(tableView -> createRow());
mTableView.getSelectionModel().selectedItemProperty().addListener((observable, oldRow, newRow) -> selected(newRow));
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, TABLE_KEY, userPreferences.getSwingPreference(),
SWING_TABLE_KEY);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox toolbar = new HBox(5, spacer, new ColumnConfigurationButton(mTableColumnMonitor, "Now Playing Columns"));
toolbar.setAlignment(Pos.CENTER_RIGHT);
toolbar.setPadding(new Insets(2, 2, 2, 2));
VBox.setVgrow(mTableView, Priority.ALWAYS);
getChildren().addAll(toolbar, mTableView);
for(ChannelMetadata channelMetadata: mChannelMetadataModel.addListener(this))
{
addRow(channelMetadata);
}
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Adds the listener to receive the processing chain for the channel that the user selects in the table, or null
* when the selection is cleared. Notifications arrive on the JavaFX application thread.
*/
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
{
mSelectedProcessingChainBroadcaster.addListener(listener);
}
/**
* Prepares this view for disposal.
*/
public void dispose()
{
mChannelMetadataModel.removeListener(this);
MyEventBus.getGlobalEventBus().unregister(this);
}
/**
* Receives preference update notifications via the event bus
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
//Talkgroup format changes the identifiers and the application preference can change the alias colors
if(preferenceType == PreferenceType.TALKGROUP_FORMAT || preferenceType == PreferenceType.APPLICATION)
{
Platform.runLater(() -> mRows.forEach(Row::refresh));
}
}
@Override
public void added(ChannelAndMetadata channelAndMetadata)
{
List<ChannelMetadata> channelMetadata = new ArrayList<>(channelAndMetadata.getChannelMetadata());
Platform.runLater(() -> {
for(ChannelMetadata metadata: channelMetadata)
{
addRow(metadata);
}
//The DMR Capacity+ rest channel rotation converts the selected channel to a traffic channel and restarts
//the previous channel. Re-select the row of the restarted channel so that the user isn't chasing it.
if(mUserSelectedChannel != null && !channelMetadata.isEmpty() &&
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
{
Row row = mRowMap.get(channelMetadata.getFirst());
if(row != null)
{
mTableView.getSelectionModel().select(row);
}
}
});
}
@Override
public void removed(ChannelMetadata channelMetadata)
{
Platform.runLater(() -> {
Row row = mRowMap.remove(channelMetadata);
if(row != null)
{
mRows.remove(row);
}
});
}
@Override
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
{
Row row = mRowMap.get(channelMetadata);
if(row != null)
{
row.scheduleRefresh();
}
}
/**
* Adds a row for the channel metadata. Call on the JavaFX application thread.
*/
private void addRow(ChannelMetadata channelMetadata)
{
if(!mRowMap.containsKey(channelMetadata))
{
Row row = new Row(channelMetadata);
mRowMap.put(channelMetadata, row);
mRows.add(row);
}
}
private void selected(Row row)
{
ProcessingChain processingChain = null;
if(row != null)
{
mUserSelectedChannel = mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata());
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
}
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
}
private TableRow<Row> createRow()
{
TableRow<Row> tableRow = new TableRow<>();
ContextMenu contextMenu = new ContextMenu();
contextMenu.setOnShowing(event -> {
contextMenu.getItems().clear();
Row row = tableRow.getItem();
Channel channel = row != null ? mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata()) : null;
if(channel != null)
{
MenuItem viewChannel = new MenuItem("View/Edit: " + channel.getShortTitle());
viewChannel.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
contextMenu.getItems().add(viewChannel);
}
else
{
MenuItem none = new MenuItem("No Actions Available");
none.setDisable(true);
contextMenu.getItems().add(none);
}
});
tableRow.setContextMenu(contextMenu);
return tableRow;
}
private List<TableColumn<Row,?>> createColumns()
{
List<TableColumn<Row,?>> columns = new ArrayList<>();
TableColumn<Row,State> status = NowPlayingCells.column("status", "Status", null, Pos.CENTER,
row -> {
ChannelStateIdentifier identifier = row.getChannelMetadata().getChannelStateIdentifier();
return identifier != null ? identifier.getValue() : null;
}, Row::revisionProperty);
status.setCellFactory(column -> new NowPlayingCells.AlignedCell<>(column)
{
@Override
protected void updateItem(State state, boolean empty)
{
super.updateItem(state, empty);
if(empty)
{
setText(null);
setStyle(null);
}
else if(state == null)
{
setText(EMPTY_STATE);
setStyle(null);
}
else
{
setText(state.getDisplayValue());
setStyle(STATE_STYLES.get(state));
}
}
});
columns.add(status);
columns.add(NowPlayingCells.column("decoder", "Decoder", null, Pos.CENTER,
row -> toText(row.getChannelMetadata().getDecoderTypeConfigurationIdentifier()), Row::revisionProperty));
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
row -> {
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getFromIdentifier());
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
}, Row::revisionProperty));
//Talker alias sent over the air by the radio (e.g. DMR or P25)
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
row -> {
Identifier talkerAlias = row.getChannelMetadata().getTalkerAliasIdentifier();
return talkerAlias != null && talkerAlias.getValue() != null ? talkerAlias.getValue().toString() : null;
}, Row::revisionProperty));
TableColumn<Row,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias",
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getFromIdentifierAliases()),
Row::revisionProperty);
fromAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
columns.add(fromAlias);
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
row -> {
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getToIdentifier());
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
}, Row::revisionProperty));
TableColumn<Row,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias", "To Alias",
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getToIdentifierAliases()),
Row::revisionProperty);
toAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
columns.add(toAlias);
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
row -> getLogicalChannelName(row.getChannelMetadata()), Row::revisionProperty));
TableColumn<Row,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null, Pos.CENTER,
row -> row.getChannelMetadata().getFrequencyConfigurationIdentifier() != null ?
row.getChannelMetadata().getFrequencyConfigurationIdentifier().getValue() : null,
Row::revisionProperty);
DecimalFormat frequencyFormat = new DecimalFormat("#.00000");
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
columns.add(frequency);
columns.add(NowPlayingCells.column("channel.name", "Channel Name", null, Pos.CENTER,
row -> toText(row.getChannelMetadata().getChannelNameConfigurationIdentifier()), Row::revisionProperty));
//The channel rows are in channel start order, the same as the Swing version of the table
for(TableColumn<Row,?> column: columns)
{
column.setSortable(false);
}
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
String[] swingColumns = {"status", "decoder", "from", "from.alias", "to", "to.alias", "channel", "frequency",
"channel.name"};
for(TableColumn<Row,?> column: columns)
{
int swingColumn = Arrays.asList(swingColumns).indexOf(column.getId());
if(swingColumn >= 0)
{
TableColumnMonitor.setLegacyIndex(column, swingColumn);
}
}
return columns;
}
/**
* Decoder logical channel name with the timeslot, if any
*/
private static String getLogicalChannelName(ChannelMetadata channelMetadata)
{
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
if(channelMetadata.hasTimeslot())
{
if(id == null)
{
return "TS:" + channelMetadata.getTimeslot();
}
return id.getValue().replace(" TS1", "").replace(" TS2", "") + " TS:" + channelMetadata.getTimeslot();
}
return id != null ? id.getValue() : null;
}
private static String toText(Identifier identifier)
{
return identifier != null ? identifier.toString() : null;
}
private static String style(String background, String foreground)
{
return "-fx-background-color: " + background + "; -fx-text-fill: " + foreground + ";";
}
/**
* Table row item for a channel metadata. The revision increments each time the channel metadata changes so that
* the row cells update.
*/
public static class Row
{
private final ChannelMetadata mChannelMetadata;
private final IntegerProperty mRevision = new SimpleIntegerProperty();
private final AtomicBoolean mRefreshPending = new AtomicBoolean();
Row(ChannelMetadata channelMetadata)
{
mChannelMetadata = channelMetadata;
}
public ChannelMetadata getChannelMetadata()
{
return mChannelMetadata;
}
public IntegerProperty revisionProperty()
{
return mRevision;
}
/**
* Updates the row cells. Call on the JavaFX application thread.
*/
void refresh()
{
mRevision.set(mRevision.get() + 1);
}
/**
* Schedules a row cell update on the JavaFX application thread. Can be called from any thread. Updates
* that arrive while one is pending are combined.
*/
void scheduleRefresh()
{
if(mRefreshPending.compareAndSet(false, true))
{
Platform.runLater(() -> {
mRefreshPending.set(false);
refresh();
});
}
}
}
}

View File

@@ -0,0 +1,407 @@
/*
* *****************************************************************************
* 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.nowplaying;
import com.google.common.base.Joiner;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.channel.IChannelDescriptor;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.event.DecodeEventHistory;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
/**
* Table of the decode events for the selected channel.
*/
public class DecodeEventView extends HistoryTableView<IDecodeEvent,IDecodeEvent> implements Listener<ProcessingChain>
{
private static final String TABLE_KEY = "now.playing.events";
private static final String SWING_TABLE_KEY = "decode.event.panel";
private final TimestampFormatter mTimestampFormatter;
private final FilterSet<IDecodeEvent> mFilterSet = new DecodeEventFilterSet();
private DecodeEventHistory mCurrentEventHistory;
private Listener<IDecodeEvent> mCurrentEventListener;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param aliasModel for aliasing identifiers
*/
public DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
{
this(iconModel, userPreferences, aliasModel, new TimestampFormatter(userPreferences));
}
private DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel,
TimestampFormatter timestampFormatter)
{
super(createColumns(iconModel, userPreferences, aliasModel, timestampFormatter), TABLE_KEY,
userPreferences.getSwingPreference(), SWING_TABLE_KEY, "Event Filter Editor", "Event Columns");
mTimestampFormatter = timestampFormatter;
getHistoryControls().setFilterSet(mFilterSet);
//Filter changes come from the Swing filter editor
mFilterSet.register(() -> Platform.runLater(this::refilter));
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Prepares this view for disposal
*/
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
receive(null);
}
/**
* Receives preference update notifications via the event bus
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT ||
preferenceType == PreferenceType.APPLICATION)
{
Platform.runLater(this::refreshAll);
}
}
@Override
protected boolean include(IDecodeEvent event)
{
return mFilterSet.canProcess(event) && mFilterSet.passes(event);
}
/**
* Shows the decode events for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentEventHistory != null)
{
mCurrentEventHistory.removeListener(mCurrentEventListener);
}
mCurrentEventHistory = null;
mCurrentEventListener = null;
if(processingChain != null)
{
DecodeEventHistory history = processingChain.getDecodeEventHistory();
//A listener for each history so that events from the previous channel are ignored after a change
Listener<IDecodeEvent> listener = new Listener<>()
{
@Override
public void receive(IDecodeEvent event)
{
Platform.runLater(() -> {
if(mCurrentEventListener == this)
{
add(event);
}
});
}
};
mCurrentEventHistory = history;
mCurrentEventListener = listener;
setItems(history.getItems());
history.addListener(listener);
getHistoryControls().setControlsEnabled(true);
}
else
{
clear();
getHistoryControls().setControlsEnabled(false);
}
}
@Override
protected void refreshAll()
{
mTimestampFormatter.update();
super.refreshAll();
}
private static List<TableColumn<Row<IDecodeEvent>,?>> createColumns(IconModel iconModel,
UserPreferences userPreferences,
AliasModel aliasModel,
TimestampFormatter timestampFormatter)
{
List<TableColumn<Row<IDecodeEvent>,?>> columns = new ArrayList<>();
TableColumn<Row<IDecodeEvent>,Long> time = NowPlayingCells.column("time", "Time", null, Pos.CENTER,
row -> row.getItem().getTimeStart(), Row::revisionProperty);
time.setCellFactory(NowPlayingCells.formatted(timestampFormatter::format));
columns.add(time);
TableColumn<Row<IDecodeEvent>,Long> duration = NowPlayingCells.column("duration", "Duration", null,
Pos.CENTER, row -> row.getItem().getDuration(), Row::revisionProperty);
DecimalFormat durationFormat = new DecimalFormat("0.0");
duration.setCellFactory(NowPlayingCells.formatted(value -> value > 0 ? durationFormat.format(value / 1e3d) : null));
columns.add(duration);
columns.add(NowPlayingCells.column("event", "Event", null, Pos.CENTER_LEFT,
row -> row.getItem().getEventType() != null ? row.getItem().getEventType().getLabel() : null,
Row::revisionProperty));
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.FROM, userPreferences),
Row::revisionProperty));
//Talker alias sent over the air by the radio (e.g. DMR, P25 or NXDN)
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
row -> getTalkerAlias(row.getItem().getIdentifierCollection()), Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias",
"From Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.FROM, aliasModel),
Row::revisionProperty);
fromAlias.setCellFactory(NowPlayingCells.alias(iconModel));
columns.add(fromAlias);
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.TO, userPreferences),
Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias",
"To Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.TO, aliasModel),
Row::revisionProperty);
toAlias.setCellFactory(NowPlayingCells.alias(iconModel));
columns.add(toAlias);
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
row -> getChannel(row.getItem()), Row::revisionProperty));
TableColumn<Row<IDecodeEvent>,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null,
Pos.CENTER, row -> {
IChannelDescriptor descriptor = row.getItem().getChannelDescriptor();
return descriptor != null && descriptor.getDownlinkFrequency() > 0 ?
descriptor.getDownlinkFrequency() : null;
}, Row::revisionProperty);
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
columns.add(frequency);
columns.add(NowPlayingCells.column("details", "Details", null, Pos.CENTER_LEFT,
row -> row.getItem().getDetails(), Row::revisionProperty));
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
List<String> swingColumns = List.of("time", "duration", "event", "from", "from.alias", "to", "to.alias",
"channel", "frequency", "details");
for(TableColumn<Row<IDecodeEvent>,?> column: columns)
{
if(swingColumns.contains(column.getId()))
{
TableColumnMonitor.setLegacyIndex(column, swingColumns.indexOf(column.getId()));
}
}
return columns;
}
/**
* Talker alias(es) in the identifier collection
*/
private static String getTalkerAlias(IdentifierCollection identifierCollection)
{
if(identifierCollection == null)
{
return null;
}
List<String> aliases = new ArrayList<>();
for(Identifier identifier: identifierCollection.getIdentifiers(Form.TALKER_ALIAS))
{
if(identifier.getValue() != null)
{
aliases.add(identifier.getValue().toString());
}
}
return aliases.isEmpty() ? null : String.join(",", aliases);
}
/**
* Identifiers with the role, without talker aliases, which have their own column
*/
private static List<Identifier> getIdentifiers(IdentifierCollection identifierCollection, Role role)
{
List<Identifier> identifiers = new ArrayList<>(identifierCollection.getIdentifiers(role));
identifiers.removeIf(identifier -> identifier.getForm() == Form.TALKER_ALIAS);
return identifiers;
}
/**
* Event timestamp formatter that uses the timestamp format preference.
*/
private static class TimestampFormatter
{
private final UserPreferences mUserPreferences;
private SimpleDateFormat mFormat;
TimestampFormatter(UserPreferences userPreferences)
{
mUserPreferences = userPreferences;
update();
}
/**
* Updates the format from the preference
*/
void update()
{
mFormat = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
}
String format(long timestamp)
{
return mFormat.format(new Date(timestamp));
}
}
/**
* Channel descriptor and timeslot for the event
*/
private static String getChannel(IDecodeEvent event)
{
IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
boolean timeslot = event.hasTimeslot() && !event.toString().contains("TS");
if(channelDescriptor != null)
{
return timeslot ? channelDescriptor + " TS" + event.getTimeslot() : channelDescriptor.toString();
}
return timeslot ? "TS" + event.getTimeslot() : null;
}
/**
* Formats the identifiers for the role as a comma separated list
*/
private static String formatIdentifiers(IdentifierCollection identifierCollection, Role role,
UserPreferences userPreferences)
{
if(identifierCollection == null)
{
return null;
}
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
if(identifiers == null || identifiers.isEmpty())
{
return null;
}
StringBuilder sb = new StringBuilder();
for(Identifier identifier: identifiers)
{
if(!sb.isEmpty())
{
sb.append(",");
}
if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO ||
identifier.getForm() == Form.PATCH_GROUP)
{
sb.append(userPreferences.getTalkgroupFormatPreference().format(identifier));
}
else
{
sb.append(identifier);
}
}
return sb.toString();
}
/**
* Aliases for the identifiers with the role. The display is styled by the first alias of the last aliased
* identifier, the same as the Swing version of the table.
*/
private static AliasDisplay getAliases(IdentifierCollection identifierCollection, Role role, AliasModel aliasModel)
{
if(identifierCollection == null)
{
return null;
}
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
if(identifiers == null || identifiers.isEmpty())
{
return null;
}
AliasList aliasList = aliasModel.getAliasList(identifierCollection);
if(aliasList == null)
{
return null;
}
StringBuilder sb = new StringBuilder();
Alias styleAlias = null;
for(Identifier identifier: identifiers)
{
List<Alias> aliases = aliasList.getAliases(identifier);
if(!aliases.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(",");
}
sb.append(Joiner.on(", ").skipNulls().join(aliases));
styleAlias = aliases.getFirst();
}
}
return styleAlias != null ? AliasDisplay.of(sb.toString(), styleAlias) : null;
}
}

View File

@@ -0,0 +1,153 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.filter.FilterEditor;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import java.awt.EventQueue;
import java.util.function.IntConsumer;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Toolbar for a history table: filter editor, clear, history size and column configuration controls.
*/
public class HistoryControls<T> extends HBox
{
/**
* Default number of items in the history
*/
public static final int DEFAULT_HISTORY_SIZE = 200;
private static final int MAXIMUM_HISTORY_SIZE = 2000;
private final String mFilterEditorTitle;
private final Button mFilterButton = new Button("Filters");
private final Button mClearButton = new Button("Clear");
private final Label mHistoryLabel = new Label("History:");
private final Slider mHistorySlider = new Slider(0, MAXIMUM_HISTORY_SIZE, DEFAULT_HISTORY_SIZE);
private final Label mHistoryValueLabel = new Label(String.valueOf(DEFAULT_HISTORY_SIZE));
//Swing filter editor, accessed on the Swing event thread
private FilterEditor<T> mFilterEditor;
private FilterSet<T> mFilterSet;
/**
* Constructs an instance
* @param filterEditorTitle for the filter editor window
* @param clear action for the clear button
* @param historySizeListener to receive history size changes
* @param tableColumnMonitor for the column configuration button
* @param columnsTitle for the column configuration editor
*/
public HistoryControls(String filterEditorTitle, Runnable clear, IntConsumer historySizeListener,
TableColumnMonitor<?> tableColumnMonitor, String columnsTitle)
{
super(5);
mFilterEditorTitle = filterEditorTitle;
setAlignment(Pos.CENTER_LEFT);
setPadding(new Insets(3, 2, 3, 2));
mFilterButton.setTooltip(new Tooltip("Edit filters"));
mFilterButton.setOnAction(event -> showFilterEditor());
mClearButton.setTooltip(new Tooltip("Clears the history"));
mClearButton.setOnAction(event -> clear.run());
mHistorySlider.setTooltip(new Tooltip("Adjust history size. Double-click to reset to default " +
DEFAULT_HISTORY_SIZE));
mHistorySlider.setBlockIncrement(25);
mHistorySlider.setPrefWidth(200);
mHistorySlider.valueProperty().addListener((observable, oldValue, newValue) -> {
int size = newValue.intValue();
mHistoryValueLabel.setText(String.valueOf(size));
historySizeListener.accept(size);
});
mHistorySlider.setOnMouseClicked(event -> {
if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2)
{
mHistorySlider.setValue(DEFAULT_HISTORY_SIZE);
}
});
mHistoryValueLabel.setMinWidth(40);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
getChildren().addAll(mFilterButton, mClearButton, mHistoryLabel, mHistorySlider, mHistoryValueLabel, spacer,
new ColumnConfigurationButton(tableColumnMonitor, columnsTitle));
setControlsEnabled(false);
}
/**
* Sets the enabled state of the history controls. The column configuration button is always enabled.
*/
public void setControlsEnabled(boolean enabled)
{
mFilterButton.setDisable(!enabled);
mClearButton.setDisable(!enabled);
mHistoryLabel.setDisable(!enabled);
mHistorySlider.setDisable(!enabled);
mHistoryValueLabel.setDisable(!enabled);
}
/**
* Updates the filter set for the filter editor.
* @param filterSet to edit, or null
*/
public void setFilterSet(FilterSet<T> filterSet)
{
mFilterSet = filterSet;
EventQueue.invokeLater(() -> {
if(mFilterEditor != null && filterSet != null)
{
mFilterEditor.updateFilterSet(filterSet);
}
});
}
/**
* Shows the filter editor. The filter editor is a Swing window.
*/
private void showFilterEditor()
{
FilterSet<T> filterSet = mFilterSet;
if(filterSet != null)
{
EventQueue.invokeLater(() -> {
if(mFilterEditor == null)
{
mFilterEditor = new FilterEditor<>(mFilterEditorTitle, null, filterSet);
}
mFilterEditor.setVisible(true);
mFilterEditor.toFront();
});
}
}
}

View File

@@ -0,0 +1,206 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.preference.swing.SwingPreference;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Table of a history of items, newest first, with filter, clear, history size and column configuration controls.
*
* Use this class on the JavaFX application thread.
*
* @param <T> type of the history items
* @param <F> type of the filtered objects
*/
public abstract class HistoryTableView<T,F> extends VBox
{
private final ObservableList<Row<T>> mRows = FXCollections.observableArrayList();
private final FilteredList<Row<T>> mFilteredRows = new FilteredList<>(mRows);
private final Map<T,Row<T>> mRowMap = new HashMap<>();
private final TableView<Row<T>> mTableView = new TableView<>();
private final TableColumnMonitor<Row<T>> mTableColumnMonitor;
private final HistoryControls<F> mHistoryControls;
private int mHistorySize = HistoryControls.DEFAULT_HISTORY_SIZE;
/**
* Constructs an instance
* @param columns for the table
* @param tableKey for the stored column settings
* @param swingPreference with the legacy Swing column widths
* @param swingTableKey of the legacy Swing table
* @param filterEditorTitle for the filter editor window
* @param columnsTitle for the column configuration editor window
*/
protected HistoryTableView(List<TableColumn<Row<T>,?>> columns, String tableKey, SwingPreference swingPreference,
String swingTableKey, String filterEditorTitle, String columnsTitle)
{
mTableView.getColumns().addAll(columns);
SortedList<Row<T>> sortedRows = new SortedList<>(mFilteredRows);
sortedRows.comparatorProperty().bind(mTableView.comparatorProperty());
mTableView.setItems(sortedRows);
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
mTableView.setPlaceholder(new Label(""));
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, tableKey, swingPreference, swingTableKey);
mHistoryControls = new HistoryControls<>(filterEditorTitle, this::clear, this::setHistorySize,
mTableColumnMonitor, columnsTitle);
VBox.setVgrow(mTableView, Priority.ALWAYS);
getChildren().addAll(mHistoryControls, mTableView);
refilter();
}
/**
* Indicates if the item passes the current filters
*/
protected abstract boolean include(T item);
/**
* History controls toolbar
*/
protected HistoryControls<F> getHistoryControls()
{
return mHistoryControls;
}
/**
* Re-applies the filters to the items, after a filter change.
*/
protected void refilter()
{
mFilteredRows.setPredicate(row -> include(row.getItem()));
}
/**
* Adds the item to the top of the history, or updates the row of the item when it is already in the history.
* Removes the oldest items when the history is larger than the history size.
*/
protected void add(T item)
{
Row<T> row = mRowMap.get(item);
if(row != null)
{
//Replace the row with itself so that the filter re-checks the updated item
int index = mRows.indexOf(row);
if(index >= 0)
{
mRows.set(index, row);
}
row.refresh();
}
else
{
row = new Row<>(item);
mRowMap.put(item, row);
mRows.addFirst(row);
trim();
}
}
/**
* Replaces the history with the items, oldest first.
*/
protected void setItems(List<T> items)
{
clear();
for(T item: items)
{
add(item);
}
}
/**
* Removes all items from the history
*/
protected void clear()
{
mRows.clear();
mRowMap.clear();
}
/**
* Updates the cells of all rows, after a change to the display formats.
*/
protected void refreshAll()
{
mRows.forEach(Row::refresh);
}
private void setHistorySize(int historySize)
{
mHistorySize = historySize;
trim();
}
private void trim()
{
while(mRows.size() > mHistorySize)
{
mRowMap.remove(mRows.removeLast().getItem());
}
}
/**
* Table row item for a history item. The revision increments each time the item changes so that the row cells
* update.
*/
public static class Row<T>
{
private final T mItem;
private final IntegerProperty mRevision = new SimpleIntegerProperty();
Row(T item)
{
mItem = item;
}
public T getItem()
{
return mItem;
}
public IntegerProperty revisionProperty()
{
return mRevision;
}
void refresh()
{
mRevision.set(mRevision.get() + 1);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.MessageHistory;
import io.github.dsheirer.message.StuffBitsMessage;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.event.MessageItem;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
/**
* Table of the decoded messages for the selected channel.
*/
public class MessageActivityView extends HistoryTableView<MessageItem,IMessage> implements Listener<ProcessingChain>
{
private static final String TABLE_KEY = "now.playing.messages";
private static final String SWING_TABLE_KEY = "message.activity.panel";
private MessageHistory mCurrentMessageHistory;
private Listener<IMessage> mCurrentMessageListener;
private FilterSet<IMessage> mMessageFilterSet;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param userPreferences for the column settings
*/
public MessageActivityView(UserPreferences userPreferences)
{
super(createColumns(), TABLE_KEY, userPreferences.getSwingPreference(), SWING_TABLE_KEY,
"Message Filter Editor", "Message Columns");
}
@Override
protected boolean include(MessageItem item)
{
FilterSet<IMessage> filterSet = mMessageFilterSet;
if(filterSet != null && item.getMessage() != null)
{
return filterSet.canProcess(item.getMessage()) && filterSet.passes(item.getMessage());
}
return false;
}
/**
* Shows the messages for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentMessageHistory != null)
{
mCurrentMessageHistory.removeListener(mCurrentMessageListener);
}
//Unregister from changes made to the filter set
if(mMessageFilterSet != null)
{
mMessageFilterSet.register(null);
}
mCurrentMessageHistory = null;
mCurrentMessageListener = null;
if(processingChain != null)
{
MessageHistory history = processingChain.getMessageHistory();
FilterSet<IMessage> filterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
mMessageFilterSet = filterSet;
//Filter changes come from the Swing filter editor
filterSet.register(() -> Platform.runLater(() -> {
if(mMessageFilterSet == filterSet)
{
refilter();
}
}));
getHistoryControls().setFilterSet(filterSet);
//A listener for each history so that messages from the previous channel are ignored after a change
Listener<IMessage> listener = new Listener<>()
{
@Override
public void receive(IMessage message)
{
//Don't process tail bits or stuff bits message fragments
if(!(message instanceof StuffBitsMessage))
{
MessageItem item = new MessageItem(message);
Platform.runLater(() -> {
if(mCurrentMessageListener == this)
{
add(item);
}
});
}
}
};
mCurrentMessageHistory = history;
mCurrentMessageListener = listener;
List<MessageItem> items = new ArrayList<>();
for(IMessage message: history.getItems())
{
items.add(new MessageItem(message));
}
setItems(items);
refilter();
history.addListener(listener);
getHistoryControls().setControlsEnabled(true);
}
else
{
mMessageFilterSet = null;
clear();
getHistoryControls().setControlsEnabled(false);
}
}
private static List<TableColumn<Row<MessageItem>,?>> createColumns()
{
SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
List<TableColumn<Row<MessageItem>,?>> columns = new ArrayList<>();
columns.add(NowPlayingCells.column("time", "Time", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimestamp(timeFormat), null));
columns.add(NowPlayingCells.column("protocol", "Protocol", null, Pos.CENTER_LEFT,
row -> row.getItem().getProtocol(), null));
columns.add(NowPlayingCells.column("timeslot", "Timeslot", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimeslot(), null));
columns.add(NowPlayingCells.column("message", "Message", null, Pos.CENTER_LEFT,
row -> row.getItem().getText(), null));
return columns;
}
}

View File

@@ -0,0 +1,166 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.Icon;
import io.github.dsheirer.icon.IconModel;
import java.util.function.Function;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.image.ImageView;
import javafx.util.Callback;
import javax.swing.UIManager;
/**
* Table column and cell helpers for the Now Playing tables.
*/
public final class NowPlayingCells
{
private static final String ALIGNMENT = "now.playing.cell.alignment";
private NowPlayingCells()
{
}
/**
* Creates a table column.
* @param id that identifies the column for storing the column settings. Don't change it, or users lose the
* stored order, visibility and width of the column.
* @param text for the column header
* @param configurationName for the column in the column configuration editor, or null to use the header text
* @param alignment of the cell contents
* @param value of the column for the row item. The value is recalculated each time the row revision changes.
* @param revision of the row item, or null when the row value doesn't change
*/
public static <S,T> TableColumn<S,T> column(String id, String text, String configurationName, Pos alignment,
Function<S,T> value, Function<S,ObservableValue<?>> revision)
{
TableColumn<S,T> column = new TableColumn<>(text);
column.setId(id);
if(configurationName != null)
{
TableColumnMonitor.setName(column, configurationName);
}
column.getProperties().put(ALIGNMENT, alignment);
column.setCellFactory(formatted(Object::toString));
if(revision == null)
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue())));
}
else
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue()),
revision.apply(param.getValue())));
}
return column;
}
/**
* Cell factory that shows the cell value formatted as text.
* @param formatter for non-null values. Can return null for an empty cell.
*/
public static <S,T> Callback<TableColumn<S,T>,TableCell<S,T>> formatted(Function<T,String> formatter)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(T item, boolean empty)
{
super.updateItem(item, empty);
setText(empty || item == null ? null : formatter.apply(item));
}
};
}
/**
* Cell factory for alias values that shows the alias names in the alias color with the alias icon.
* @param iconModel for the alias icons
*/
public static <S> Callback<TableColumn<S,AliasDisplay>,TableCell<S,AliasDisplay>> alias(IconModel iconModel)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(AliasDisplay item, boolean empty)
{
super.updateItem(item, empty);
if(empty || item == null)
{
setText(null);
setGraphic(null);
setStyle(null);
}
else
{
setText(item.text());
Icon icon = iconModel.getIcon(item.iconName());
setGraphic(icon != null && icon.getFxImage() != null ? new ImageView(icon.getFxImage()) : null);
java.awt.Color color = new java.awt.Color(item.color());
java.awt.Color background = UIManager.getColor("Table.background");
java.awt.Color readable = background != null ?
ThemeManager.getInstance().getReadableColor(color, background) : color;
String fill = toWebColor(readable.getRGB());
//An inline style, since the themed table row style otherwise replaces the text fill
setStyle("-fx-text-fill: " + fill + ";");
}
}
};
}
/**
* Formats the RGB value as a web color.
* @param rgb color value
* @return web color, e.g. #FF0000
*/
public static String toWebColor(int rgb)
{
return String.format("#%06X", rgb & 0xFFFFFF);
}
/**
* Table cell that aligns its contents with the alignment of its column.
*/
public static class AlignedCell<S,T> extends TableCell<S,T>
{
/**
* Constructs an instance
* @param column of the cell
*/
public AlignedCell(TableColumn<S,T> column)
{
if(column.getProperties().get(ALIGNMENT) instanceof Pos alignment)
{
setAlignment(alignment);
}
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
/**
* Swing panel that holds the JavaFX Now Playing view for the main application window. The JavaFX view is created
* on the JavaFX application thread after construction.
*/
public class NowPlayingPanel extends JFXPanel
{
private NowPlayingView mNowPlayingView;
private boolean mDetailTabsVisible;
/**
* Constructs an instance
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the channel detail tabs
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
mDetailTabsVisible = detailTabsVisible;
Platform.setImplicitExit(false);
ThemeManager.getInstance().registerSwing(this);
Platform.runLater(() -> {
NowPlayingView view;
synchronized(NowPlayingPanel.this)
{
view = new NowPlayingView(playlistManager, iconModel, userPreferences, mDetailTabsVisible);
mNowPlayingView = view;
}
Scene scene = new Scene(view);
ThemeManager.getInstance().register(scene);
setScene(scene);
});
}
/**
* Changes the visibility of the channel details tabs. Can be called from any thread.
* @param visible true to show or false to hide.
*/
public synchronized void setDetailTabsVisible(boolean visible)
{
mDetailTabsVisible = visible;
if(mNowPlayingView != null)
{
NowPlayingView view = mNowPlayingView;
Platform.runLater(() -> view.setDetailTabsVisible(visible));
}
}
}

View File

@@ -0,0 +1,121 @@
/*
* *****************************************************************************
* 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.nowplaying;
import io.github.dsheirer.gui.channel.ChannelSpectrumView;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.geometry.Orientation;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
/**
* Now Playing view: the table of currently processing channels above the details, events, messages and channel tabs
* for the selected channel.
*
* Use this class on the JavaFX application thread.
*/
public class NowPlayingView extends SplitPane
{
private static final double DIVIDER_POSITION = 0.5;
private final ChannelMetadataView mChannelMetadataView;
private final ChannelDetailView mChannelDetailView;
private final DecodeEventView mDecodeEventView;
private final MessageActivityView mMessageActivityView;
private final ChannelSpectrumView mChannelSpectrumView;
private final TabPane mTabPane = new TabPane();
private boolean mDetailTabsVisible;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the detail tabs
*/
public NowPlayingView(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
setOrientation(Orientation.VERTICAL);
mChannelMetadataView = new ChannelMetadataView(playlistManager.getChannelProcessingManager(), iconModel, userPreferences);
mChannelDetailView = new ChannelDetailView(playlistManager.getChannelProcessingManager());
mDecodeEventView = new DecodeEventView(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityView = new MessageActivityView(userPreferences);
mChannelSpectrumView = new ChannelSpectrumView(playlistManager);
Tab detailsTab = new Tab("Details", mChannelDetailView);
Tab channelTab = new Tab("Channel", mChannelSpectrumView);
mTabPane.getTabs().addAll(detailsTab, new Tab("Events", mDecodeEventView),
new Tab("Messages", mMessageActivityView), channelTab);
mTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
//Turn the channel spectrum processing on only while the channel tab is selected
mTabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
mChannelSpectrumView.setPanelVisible(newTab == channelTab);
//Show current details each time the user opens the details tab
if(newTab == detailsTab)
{
mChannelDetailView.refresh();
}
});
mChannelMetadataView.addProcessingChainSelectionListener(mChannelDetailView);
mChannelMetadataView.addProcessingChainSelectionListener(mDecodeEventView);
mChannelMetadataView.addProcessingChainSelectionListener(mMessageActivityView);
mChannelMetadataView.addProcessingChainSelectionListener(mChannelSpectrumView);
getItems().add(mChannelMetadataView);
setDetailTabsVisible(detailTabsVisible);
}
/**
* Changes the visibility of the channel details tabs.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
if(visible && !getItems().contains(mTabPane))
{
getItems().add(mTabPane);
setDividerPositions(DIVIDER_POSITION);
}
else if(!visible)
{
getItems().remove(mTabPane);
}
mDetailTabsVisible = visible;
//The channel tab is hidden with the detail tabs, so stop its spectrum processing
mChannelSpectrumView.setPanelVisible(mDetailTabsVisible &&
mTabPane.getSelectionModel().getSelectedIndex() == mTabPane.getTabs().size() - 1);
}
/**
* Prepares this view for disposal.
*/
public void dispose()
{
mChannelMetadataView.dispose();
mDecodeEventView.dispose();
}
}

View File

@@ -21,7 +21,7 @@ package io.github.dsheirer.gui.playlist.channel;
import io.github.dsheirer.gui.control.AccessibilityUtil;
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
import io.github.dsheirer.gui.control.DbPowerMeter;
import io.github.dsheirer.gui.power.PowerMeter;
import io.github.dsheirer.gui.control.HexFormatter;
import io.github.dsheirer.gui.control.IntegerFormatter;
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
@@ -80,8 +80,8 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
private TextField mSquelchThresholdField;
private ToggleSwitch mSquelchAutoTrackSwitch;
private TextFormatter<Integer> mTalkgroupTextFormatter;
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER,
(int)DbPowerMeter.DEFAULT_MAXIMUM_POWER);
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)PowerMeter.DEFAULT_MINIMUM_POWER,
(int)PowerMeter.DEFAULT_MAXIMUM_POWER);
private EventLogConfigurationEditor mEventLogConfigurationEditor;
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);

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

@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@@ -92,8 +91,9 @@ public class AgencyEditor extends VBox
if(agencies != null && !agencies.isEmpty())
{
Collections.sort(agencies, new AgencyComparator());
//The agencies list belongs to a cached state or county info shared with other callers, so sort the items
getAgencyListView().getItems().addAll(agencies);
getAgencyListView().getItems().sort(new AgencyComparator());
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);

View File

@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.type.Category;
import io.github.dsheirer.rrapi.type.Frequency;
import io.github.dsheirer.rrapi.type.Mode;
@@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane
{
clear();
if(categories != null)
{
Collections.sort(categories, new CategoryComparator());
}
if(categories != null && !categories.isEmpty())
{
//The categories list belongs to a cached agency or county info shared with other callers, so change a copy
List<Category> sorted = new ArrayList<>(categories);
sorted.sort(new CategoryComparator());
// Only allow the combined list of all categories if there aren't that many
if (categories.size() < 10) {
categories.add(0, ALL_CATEGORIES);
if (sorted.size() < 10) {
sorted.add(0, ALL_CATEGORIES);
}
getCategoryComboBox().getItems().addAll(categories);
getCategoryComboBox().getSelectionModel().select(categories.get(0));
getCategoryComboBox().getItems().addAll(sorted);
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
}
else
{
@@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane
Collections.sort(frequencies, new FrequencyComparator());
//Load the cached modes map here so that the mode column doesn't call the service on the FX
//thread. On failure, the mode column shows an error and the frequencies are still listed.
try
{
mRadioReference.getService().getModesMap();
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to retrieve radio reference modes - " + rre.getMessage());
}
Platform.runLater(() -> {
getFrequencyTableView().getItems().addAll(frequencies);
setLoading(false);
@@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane
catch(Throwable t)
{
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait());
Platform.runLater(() -> {
setLoading(false);
new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait();
});
}
});
}

View File

@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
if(item != null)
{
mFrequency = (long)(item.getDownlink() * 1E6);
mFrequency = item.getDownlinkHz();
getAlphaTagTextField().setText(item.getAlphaTag());
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
getToneTextField().setText(item.getTone());
@@ -413,46 +413,7 @@ public class FrequencyEditor extends VBox
*/
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
{
if(modeDecoderType.hasDecoderType())
{
Channel channel = new Channel();
channel.setSystem(system);
channel.setSite(site);
channel.setName(name);
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequency);
channel.setSourceConfiguration(sourceConfigTuner);
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
{
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
}
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
}
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
{
if(modeDecoderType == ModeDecoderType.NXDN48)
{
nxdn.setTransmissionMode(TransmissionMode.M4800);
}
else if(modeDecoderType == ModeDecoderType.NXDN96)
{
nxdn.setTransmissionMode(TransmissionMode.M9600);
}
}
channel.setDecodeConfiguration(decodeConfiguration);
return channel;
}
else
{
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
}
return null;
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name);
}
private TextField getSystemTextField()

View File

@@ -0,0 +1,436 @@
/*
* *****************************************************************************
* 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.radioreference;
import com.google.common.annotations.VisibleForTesting;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.util.ThreadPool;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.stage.Window;
/**
* Dialog to set the user's location, used to rank radio reference results by distance. The location can be looked
* up from a United States zip code (premium radio reference account) or entered as decimal degrees.
*/
public class LocationDialog extends Dialog<LocationDialog.Result>
{
/**
* Button to clear the location
*/
public static final ButtonType CLEAR = new ButtonType("Clear", ButtonBar.ButtonData.LEFT);
private static final String COORDINATE = "([-+]?\\d+(?:[.,]\\d+)?)\\s*°?\\s*([NSEWnsew])?";
/**
* A latitude and longitude pair, e.g. "43.0481, -76.1474" as copied from Google Maps, or "43.0481° N, 76.1474° W".
* A comma separator must be followed by whitespace, so that a decimal comma isn't taken for a separator.
*/
private static final Pattern COORDINATE_PAIR = Pattern.compile("^\\s*" + COORDINATE + "\\s*(?:,\\s+|;\\s*|\\s+)" +
COORDINATE + "\\s*$");
private static final Pattern SINGLE_COORDINATE = Pattern.compile("^\\s*" + COORDINATE + "\\s*$");
private final RadioReference mRadioReference;
private final TextField mZipcodeText = new TextField();
private final Button mLookupButton = new Button("Look Up");
private final Label mLookupStatus = new Label();
private final TextField mLatitudeText = new TextField();
private final TextField mLongitudeText = new TextField();
private final Label mValidationText = new Label();
private String mLabel;
private boolean mUpdatingFields;
/**
* Result of the dialog
* @param location to store, or null to clear the location
*/
public record Result(Location location)
{
}
/**
* Constructs an instance
* @param radioReference service, for zip code lookups
* @param current location, or null
* @param owner window
*/
public LocationDialog(RadioReference radioReference, Location current, Window owner)
{
mRadioReference = radioReference;
setTitle("sdrtrunk - Location");
setHeaderText("Your location is shown on the map and used to find nearby Radio Reference frequencies");
if(owner != null)
{
initOwner(owner);
}
getDialogPane().setContent(createContent());
getDialogPane().getButtonTypes().addAll(CLEAR, ButtonType.OK, ButtonType.CANCEL);
if(current != null)
{
mUpdatingFields = true;
mLatitudeText.setText(format(current.latitude()));
mLongitudeText.setText(format(current.longitude()));
mUpdatingFields = false;
mLabel = current.label();
}
else
{
getDialogPane().lookupButton(CLEAR).setDisable(true);
}
BooleanBinding valid = Bindings.createBooleanBinding(() -> getLocation() != null,
mLatitudeText.textProperty(), mLongitudeText.textProperty());
getDialogPane().lookupButton(ButtonType.OK).disableProperty().bind(valid.not());
updateValidationText();
setResultConverter(buttonType -> {
if(buttonType == ButtonType.OK)
{
return new Result(getLocation());
}
else if(buttonType == CLEAR)
{
return new Result(null);
}
return null;
});
}
private Node createContent()
{
GridPane gridPane = new GridPane();
gridPane.setPadding(new Insets(10, 10, 5, 10));
gridPane.setHgap(5.0);
gridPane.setVgap(5.0);
Label zipcodeLabel = new Label("Zip Code:");
zipcodeLabel.setLabelFor(mZipcodeText);
GridPane.setHalignment(zipcodeLabel, HPos.RIGHT);
gridPane.add(zipcodeLabel, 0, 0);
mZipcodeText.setPrefColumnCount(6);
mZipcodeText.setOnAction(event -> lookup());
gridPane.add(mZipcodeText, 1, 0);
mLookupButton.setOnAction(event -> lookup());
gridPane.add(mLookupButton, 2, 0);
//Zip code lookups need a premium account
boolean premium = mRadioReference.premiumAccountProperty().get();
mZipcodeText.setDisable(!premium);
mLookupButton.setDisable(!premium);
mLookupStatus.setText(premium ? "United States zip codes only" :
"Zip code lookup requires a premium Radio Reference login");
mLookupStatus.setWrapText(true);
gridPane.add(mLookupStatus, 1, 1, 4, 1);
Label latitudeLabel = new Label("Latitude:");
latitudeLabel.setLabelFor(mLatitudeText);
GridPane.setHalignment(latitudeLabel, HPos.RIGHT);
gridPane.add(latitudeLabel, 0, 2);
mLatitudeText.setPrefColumnCount(10);
mLatitudeText.setPromptText("43.0481");
GridPane.setHgrow(mLatitudeText, Priority.ALWAYS);
gridPane.add(mLatitudeText, 1, 2, 2, 1);
Label longitudeLabel = new Label("Longitude:");
longitudeLabel.setLabelFor(mLongitudeText);
GridPane.setHalignment(longitudeLabel, HPos.RIGHT);
gridPane.add(longitudeLabel, 3, 2);
mLongitudeText.setPrefColumnCount(10);
mLongitudeText.setPromptText("-76.1474");
GridPane.setHgrow(mLongitudeText, Priority.ALWAYS);
gridPane.add(mLongitudeText, 4, 2);
Label hint = new Label("Decimal degrees. You can paste \"latitude, longitude\" from a map into either field.");
hint.setWrapText(true);
gridPane.add(hint, 1, 3, 4, 1);
mValidationText.setWrapText(true);
//Inline style, because the theme stylesheets override a label's text fill
mValidationText.setStyle("-fx-text-fill: orangered;");
gridPane.add(mValidationText, 1, 4, 4, 1);
mLatitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
mLongitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
return gridPane;
}
/**
* Handles an edit of either coordinate field: splits a pasted pair into both fields and forgets the zip code label
* because the location no longer comes from the zip code.
*/
private void coordinatesEdited(String text)
{
if(mUpdatingFields)
{
return;
}
mLabel = null;
double[] pair = parsePair(text);
if(pair != null)
{
//Changing a text field from its own change listener isn't allowed, so split the pair afterwards
Platform.runLater(() -> setCoordinates(pair[0], pair[1]));
}
updateValidationText();
}
private void setCoordinates(double latitude, double longitude)
{
mUpdatingFields = true;
mLatitudeText.setText(format(latitude));
mLongitudeText.setText(format(longitude));
mUpdatingFields = false;
updateValidationText();
}
private void updateValidationText()
{
String latitudeText = mLatitudeText.getText();
String longitudeText = mLongitudeText.getText();
if(isBlank(latitudeText) && isBlank(longitudeText))
{
mValidationText.setText(null);
}
else if(isBlank(latitudeText) || isBlank(longitudeText))
{
mValidationText.setText("Enter both a latitude and a longitude");
}
else if(Double.isNaN(parseCoordinate(latitudeText, true)))
{
mValidationText.setText("Latitude must be a number from -90 to 90");
}
else if(Double.isNaN(parseCoordinate(longitudeText, false)))
{
mValidationText.setText("Longitude must be a number from -180 to 180");
}
else
{
mValidationText.setText(null);
}
}
/**
* Location from the fields
* @return location, or null if the fields aren't valid
*/
private Location getLocation()
{
double latitude = parseCoordinate(mLatitudeText.getText(), true);
double longitude = parseCoordinate(mLongitudeText.getText(), false);
if(Location.isValid(latitude, longitude))
{
return new Location(latitude, longitude, mLabel);
}
return null;
}
/**
* Looks up the zip code in the background and fills the coordinates
*/
private void lookup()
{
String zipcode = mZipcodeText.getText() != null ? mZipcodeText.getText().trim() : "";
if(!zipcode.matches("\\d{5}"))
{
mLookupStatus.setText("Enter a five digit zip code");
return;
}
mLookupButton.setDisable(true);
mLookupStatus.setText("Looking up " + zipcode + "...");
ThreadPool.CACHED.execute(() -> {
Location location = null;
String error = null;
try
{
location = mRadioReference.lookupZipcode(Integer.parseInt(zipcode));
}
catch(RadioReferenceException rre)
{
error = switch(rre.getErrorType())
{
case NETWORK, TIMEOUT -> "Unable to reach Radio Reference";
case INVALID_REQUEST -> "Radio Reference has no location for zip code " + zipcode;
default -> "Lookup failed: " + rre.getMessage();
};
}
catch(RuntimeException re)
{
error = "Lookup failed: " + re.getMessage();
}
final Location result = location;
final String message = error;
Platform.runLater(() -> {
mLookupButton.setDisable(false);
if(result != null)
{
setCoordinates(result.latitude(), result.longitude());
mLabel = result.label();
mLookupStatus.setText(result.label());
}
else
{
mLookupStatus.setText(message);
}
});
});
}
/**
* Parses a latitude and longitude pair
* @param text to parse
* @return latitude and longitude, or null if the text isn't a valid pair
*/
@VisibleForTesting
static double[] parsePair(String text)
{
if(text == null)
{
return null;
}
Matcher matcher = COORDINATE_PAIR.matcher(text);
if(matcher.matches())
{
double latitude = toDegrees(matcher.group(1), matcher.group(2), true);
double longitude = toDegrees(matcher.group(3), matcher.group(4), false);
if(Location.isValid(latitude, longitude))
{
return new double[]{latitude, longitude};
}
}
return null;
}
/**
* Parses one coordinate in decimal degrees, with an optional degree sign and hemisphere letter
* @param text to parse
* @param latitude true for a latitude, false for a longitude
* @return degrees, or NaN if the text isn't a valid coordinate
*/
@VisibleForTesting
static double parseCoordinate(String text, boolean latitude)
{
if(text == null)
{
return Double.NaN;
}
Matcher matcher = SINGLE_COORDINATE.matcher(text);
if(matcher.matches())
{
double degrees = toDegrees(matcher.group(1), matcher.group(2), latitude);
if(Math.abs(degrees) <= (latitude ? 90.0 : 180.0))
{
return degrees;
}
}
return Double.NaN;
}
/**
* Converts a number and an optional hemisphere letter to signed degrees
* @return degrees, or NaN if the hemisphere letter doesn't match the coordinate type
*/
private static double toDegrees(String number, String hemisphere, boolean latitude)
{
//Accept a decimal comma
double degrees = Double.parseDouble(number.replace(',', '.'));
if(hemisphere != null)
{
char letter = Character.toUpperCase(hemisphere.charAt(0));
boolean latitudeLetter = letter == 'N' || letter == 'S';
if(latitudeLetter != latitude || degrees < 0)
{
return Double.NaN;
}
if(letter == 'S' || letter == 'W')
{
degrees = -degrees;
}
}
return degrees;
}
/**
* Formats degrees with up to six decimal places (about 0.1 meter)
*/
@VisibleForTesting
static String format(double degrees)
{
return BigDecimal.valueOf(degrees).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
private static boolean isBlank(String text)
{
return text == null || text.isBlank();
}
}

View File

@@ -24,6 +24,7 @@ import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.response.Fault;
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.util.ThreadPool;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
@@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.ConnectException;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
@@ -60,6 +60,8 @@ import java.util.Date;
public class LoginDialog extends Dialog<AuthorizationInformation>
{
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
private static final String TEST_CONNECTION = "Test Connection";
private static final String TESTING_CONNECTION = "Testing...";
private UserPreferences mUserPreferences;
private TextField mUserNameText;
private PasswordField mPasswordField;
@@ -278,7 +280,7 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
{
if(mTestConnectionButton == null)
{
mTestConnectionButton = new Button("Test Connection");
mTestConnectionButton = new Button(TEST_CONNECTION);
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
{
@@ -316,98 +318,26 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
return;
}
Platform.runLater(new Runnable()
{
@Override
public void run()
getTestConnectionButton().setText(TESTING_CONNECTION);
//The test calls the web service, which can take up to the connect and request timeouts, so it runs
//off the FX thread and the result is shown on the FX thread
ThreadPool.CACHED.execute(() -> {
RadioReference.LoginStatus loginStatus = null;
Exception error = null;
try
{
boolean success = false;
RadioReference.LoginStatus loginStatus;
try
{
loginStatus = RadioReference.testConnectionWithExp(userName, password);
if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
{
getTestPassIcon().setVisible(true);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(false);
//Success is only shown as an icon, so report it in the button's accessible name
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
}
else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(true);
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
alert.setHeaderText("Expired Premium Account");
alert.setTitle("Radio Reference Account issue");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
Alert alert = new Alert(Alert.AlertType.ERROR, "Please verify username and password", ButtonType.OK);
alert.setHeaderText("Login Failed");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
}
else
{
// Only way to get here is via an exception
}
}
catch(RadioReferenceException rre)
{
// Set an error state
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
if(rre.getCause() instanceof ConnectException)
{
Alert alert = new Alert(Alert.AlertType.ERROR, "Please check network or radio reference availability", ButtonType.OK);
alert.setHeaderText("No Network Connection");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("No network connection to radioreference.com");
}
else if(rre.hasFault())
{
Fault fault = rre.getFault();
Alert alert = new Alert(Alert.AlertType.ERROR, fault.getFaultString(), ButtonType.OK);
alert.setHeaderText(fault.getFaultCode());
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
}
else
{
Alert alert = new Alert(Alert.AlertType.ERROR, "Error: " + rre.getMessage(), ButtonType.OK);
alert.setHeaderText("Unknown Error");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Error testing connection to radioreference.com", rre);
}
}
getTestConnectionButton().setDisable(false);
loginStatus = RadioReference.testConnectionWithExp(userName, password);
}
catch(Exception e)
{
error = e;
}
final RadioReference.LoginStatus status = loginStatus;
final Exception exception = error;
Platform.runLater(() -> showTestConnectionResult(status, exception));
});
}
});
@@ -416,6 +346,111 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
return mTestConnectionButton;
}
/**
* Shows the result of a connection test. Must be called on the FX thread.
* @param loginStatus from the test, or null if the test failed with an error
* @param error from the test, or null
*/
private void showTestConnectionResult(RadioReference.LoginStatus loginStatus, Exception error)
{
getTestConnectionButton().setText(TEST_CONNECTION);
getTestConnectionButton().setDisable(false);
//Results that arrive after the dialog closes are only logged
boolean showing = isShowing();
if(error != null)
{
// Set an error state
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
RadioReferenceException.ErrorType errorType = error instanceof RadioReferenceException rre ?
rre.getErrorType() : RadioReferenceException.ErrorType.UNKNOWN;
//The service has a 10 second connect timeout, so an unreachable service is reported as TIMEOUT as well
//as NETWORK
if(errorType == RadioReferenceException.ErrorType.NETWORK ||
errorType == RadioReferenceException.ErrorType.TIMEOUT)
{
mLog.error("No network connection to radioreference.com - " + error.getMessage());
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "No Network Connection",
"Please check network or radio reference availability");
}
}
else if(error instanceof RadioReferenceException rre && rre.hasFault())
{
Fault fault = rre.getFault();
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, fault.getFaultCode(), fault.getFaultString());
}
}
else
{
mLog.error("Error testing connection to radioreference.com", error);
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "Unknown Error", "Error: " + error.getMessage());
}
}
}
else if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
{
getTestPassIcon().setVisible(true);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(false);
//Success is only shown as an icon, so report it in the button's accessible name
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
}
else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(true);
if(showing)
{
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
alert.setHeaderText("Expired Premium Account");
alert.setTitle("Radio Reference Account issue");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
}
else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password");
}
}
}
/**
* Shows an alert for a failed connection test. Must be called on the FX thread.
*/
private void showTestFailedAlert(Alert.AlertType alertType, String header, String content)
{
Alert alert = new Alert(alertType, content, ButtonType.OK);
alert.setHeaderText(header);
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
private IconNode getTestPassIcon()
{
if(mTestPassIcon == null)

View File

@@ -70,8 +70,28 @@ public enum ModeDecoderType
}
/**
* Lookup the entry that matches the mode
*
* Lookup the matching entry for a radio reference mode name, e.g. FMN
* @param name of the mode
* @return matching entry or UNKNOWN
*/
public static ModeDecoderType fromName(String name)
{
if(name != null)
{
for(ModeDecoderType modeDecoderType : ModeDecoderType.values())
{
if(modeDecoderType.getValue().contentEquals(name.trim().toLowerCase()))
{
return modeDecoderType;
}
}
}
return UNKNOWN;
}
/**
* Lookup the matching entry for the radio reference mode
* @param mode to match
* @return matching entry or UNKNOWN
*/

View File

@@ -0,0 +1,268 @@
/*
* *****************************************************************************
* 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.radioreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM;
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
import io.github.dsheirer.rrapi.type.Site;
import io.github.dsheirer.rrapi.type.SiteFrequency;
import io.github.dsheirer.rrapi.type.System;
import io.github.dsheirer.rrapi.type.SystemInformation;
import io.github.dsheirer.source.config.SourceConfigTuner;
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Creates playlist channels from radio reference frequencies and trunked system sites.
*/
public final class RadioReferenceChannelFactory
{
private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceChannelFactory.class);
private RadioReferenceChannelFactory()
{
}
/**
* Creates a conventional channel for a radio reference frequency
* @param modeDecoderType of the frequency's radio reference mode
* @param frequency in Hertz
* @param system name for the channel
* @param site name for the channel
* @param name of the channel
* @return channel, or null when sdrtrunk has no decoder for the mode
*/
public static Channel conventional(ModeDecoderType modeDecoderType, long frequency, String system, String site,
String name)
{
if(modeDecoderType.hasDecoderType())
{
Channel channel = new Channel();
channel.setSystem(system);
channel.setSite(site);
channel.setName(name);
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequency);
channel.setSourceConfiguration(sourceConfigTuner);
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
{
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
}
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
}
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
{
if(modeDecoderType == ModeDecoderType.NXDN48)
{
nxdn.setTransmissionMode(TransmissionMode.M4800);
}
else if(modeDecoderType == ModeDecoderType.NXDN96)
{
nxdn.setTransmissionMode(TransmissionMode.M9600);
}
}
channel.setDecodeConfiguration(decodeConfiguration);
return channel;
}
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
return null;
}
/**
* Decoder type for a trunked system site's control channel. A P25 Phase II site with an FDMA control channel
* uses the Phase 1 decoder, which follows the Phase II traffic channels.
* @return decoder type, or null when sdrtrunk can't decode the site's control channel
*/
public static DecoderType getControlDecoderType(RadioReferenceDecoder decoder, System system, Site site)
{
//Motorola Type II control channels with P25 voice channels aren't supported
if(decoder.isHybridMotorolaP25(system))
{
return null;
}
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == DecoderType.P25_PHASE2 && (site == null || site.getTdmaControlChannel() == 0))
{
return DecoderType.P25_PHASE1;
}
return decoderType;
}
/**
* Control and alternate control channel frequencies of the site, control channels first
*/
public static List<Long> getControlFrequencies(Site site)
{
List<Long> frequencies = new ArrayList<>();
if(site != null && site.getSiteFrequencies() != null)
{
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isControl(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isAlternate(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
}
return frequencies;
}
/**
* Creates a channel for each of the site's repeaters on the frequencies, for systems without a control channel
* (LTR), where each repeater carries its own trunking data. The channels are named by logical channel number,
* like the playlist editor's site editor does.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site of the repeaters
* @param frequencies of the repeaters to create channels for
* @return channels, or an empty list when sdrtrunk can't decode the system
*/
public static List<Channel> repeaters(RadioReferenceDecoder decoder, System system,
SystemInformation systemInformation, Site site, List<Long> frequencies)
{
List<Channel> channels = new ArrayList<>();
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == null || site == null || site.getSiteFrequencies() == null)
{
return channels;
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(frequencies.contains(siteFrequency.getFrequencyHz()))
{
Channel channel = new Channel();
channel.setSystem(system.getName());
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : "Site " + site.getSiteNumber());
channel.setName("LCN " + siteFrequency.getLogicalChannelNumber());
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(siteFrequency.getFrequencyHz());
channel.setSourceConfiguration(sourceConfigTuner);
channels.add(channel);
}
}
return channels;
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site)
{
return trunkedSite(decoder, system, systemInformation, site, null);
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels, starting with the first frequency when it's one of them.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @param firstFrequency to list first, so the channel starts on it, or null for control channels first
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site, Long firstFrequency)
{
DecoderType decoderType = getControlDecoderType(decoder, system, site);
List<Long> frequencies = getControlFrequencies(site);
//A multi-frequency channel starts on its first frequency
if(firstFrequency != null && frequencies.remove(firstFrequency))
{
frequencies.add(0, firstFrequency);
}
if(decoderType == null || frequencies.isEmpty())
{
return null;
}
Channel channel = new Channel();
channel.setSystem(system.getName());
String siteName = "Site " + site.getSiteNumber();
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : siteName);
channel.setName(siteName);
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
if(frequencies.size() == 1)
{
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequencies.get(0));
channel.setSourceConfiguration(sourceConfigTuner);
}
else
{
SourceConfigTunerMultipleFrequency sourceConfig = new SourceConfigTunerMultipleFrequency();
for(long frequency: frequencies)
{
sourceConfig.addFrequency(frequency);
}
channel.setSourceConfiguration(sourceConfig);
}
return channel;
}
}

View File

@@ -19,6 +19,16 @@
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
import io.github.dsheirer.module.decode.dmr.DecodeConfigDMR;
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
import io.github.dsheirer.module.decode.p25.phase2.enumeration.ScrambleParameters;
import io.github.dsheirer.rrapi.type.RadioNetwork;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
@@ -382,7 +392,7 @@ public class RadioReferenceDecoder
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
timeslotFrequency.setNumber(lcn);
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
frequencies.add(timeslotFrequency);
}
@@ -432,7 +442,7 @@ public class RadioReferenceDecoder
}
}
frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0));
frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0));
}
return frequencies;
@@ -577,4 +587,126 @@ public class RadioReferenceDecoder
return null;
}
/**
* Creates a channel decode configuration for the specified site.
* @param decoderType to create
* @param site information and frequencies
* @param systemInformation with frequency mapping
* @return decode configuration
*/
public DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
{
if(decoderType == null)
{
return null;
}
switch(decoderType)
{
case DMR:
DecodeConfigDMR dmr = new DecodeConfigDMR();
List<TimeslotFrequency> timeslotFrequencies = getTimeslotFrequencies(systemInformation, site);
dmr.setTimeslotMap(timeslotFrequencies);
return dmr;
case NXDN:
Flavor flavor = getFlavor(systemInformation);
TransmissionMode mode = TransmissionMode.M4800;
/**
* 9600 bps Flavors
* ----------------------------
* 29 - NEXEDGE 9600
* 45 - Conventional Networked
*
* 4800 bps Flavors
* ---------------------------
* 11 - Narrowband Networked
* 42 - NEXEDGE 4800
* 44 - Icom IDAS Type C
*
* Type-D (also 4800 bps) Flavors
* ---------------------------
* 36 - Icom IDAS Type D
* 47 - Kenwood Type D
*/
mode = switch(flavor.getName())
{
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
default -> TransmissionMode.M4800;
};
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
List<ChannelFrequency> channelMap = getChannelMap(systemInformation, site);
nxdn.setChannelMap(channelMap);
return nxdn;
case P25_PHASE1:
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
if(isLSM(site))
{
if(p1config instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
}
}
return p1config;
case P25_PHASE2:
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
int nac = 0;
if(site != null && site.getNac() != null)
{
try
{
nac = Integer.parseInt(site.getNac(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
int wacn = 0;
int system = 0;
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
!systemInformation.getRadioNetworks().isEmpty())
{
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
if(radioNetwork.getWacn() != null)
{
try
{
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
if(radioNetwork.getSystemId() != null)
{
try
{
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
}
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
return config;
default:
return DecoderFactory.getDecodeConfiguration(decoderType);
}
}
}

View File

@@ -20,7 +20,11 @@
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.playlist.PlaylistManager;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.type.Agency;
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
@@ -31,6 +35,7 @@ import io.github.dsheirer.rrapi.type.CountyInfo;
import io.github.dsheirer.rrapi.type.State;
import io.github.dsheirer.rrapi.type.StateInfo;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.service.radioreference.UserLocation;
import io.github.dsheirer.util.ThreadPool;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
@@ -42,12 +47,12 @@ import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
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.scene.paint.Color;
import javafx.util.StringConverter;
@@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
@@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
private PlaylistManager mPlaylistManager;
private VBox mTopBox;
private HBox mCredentialsBox;
private HBox mLocationBox;
private Label mLocationText;
private final AtomicLong mLocationRequest = new AtomicLong();
private volatile String mLocationKey;
private Label mRRStatusText;
private TextField mUserNameText;
private TextField mAccountExpiresText;
@@ -113,20 +123,41 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
{
refreshCountries();
}
//The preferred county fallback location needs a premium login
refreshLocation();
});
refreshLocation();
//Updates the location when it's changed elsewhere, e.g. from the map
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Refreshes the location text when the location or the preferred county changes
* @param preferenceType that was updated
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.RADIO_REFERENCE)
{
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
String key = preference.getLocation() + "/" + preference.getPreferredCountyId();
if(!key.equals(mLocationKey))
{
mLocationKey = key;
Platform.runLater(this::refreshLocation);
}
}
}
private void login()
{
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
{
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
if(credentials != null)
{
ThreadPool.CACHED.execute(() -> accept(credentials));
}
}
//Logs in on a background thread, unless the spectral display has already logged in
mRadioReference.loginWithStoredCredentials();
}
/**
@@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
mTopBox = new VBox();
mTopBox.setSpacing(5);
mTopBox.setPadding(new Insets(10,10,10,10));
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
mTopBox.getChildren().addAll(getCredentialsBox(), getLocationBox(), getComboBoxPane());
}
return mTopBox;
@@ -416,13 +447,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
mCredentialsBox = new HBox();
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
mCredentialsBox.setSpacing(5.0);
Region leftFiller = new Region();
HBox.setHgrow(leftFiller, Priority.ALWAYS);
mCredentialsBox.getChildren().add(leftFiller);
mCredentialsBox.getChildren().add(getRRPremiumReqText());
Region rightFiller = new Region();
HBox.setHgrow(rightFiller, Priority.ALWAYS);
mCredentialsBox.getChildren().add(rightFiller);
mCredentialsBox.getChildren().add(new Label("User Name:"));
mCredentialsBox.getChildren().add(getUserNameText());
mCredentialsBox.getChildren().add(new Label("Expires:"));
@@ -446,8 +471,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
Bindings.and(
mRadioReference.availableProperty(),
mRadioReference.premiumAccountProperty().not()));
mRRStatusText.setTextFill(Color.ORANGERED);
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
//Inline style, because the theme stylesheets override a label's text fill
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
}
return mRRStatusText;
@@ -725,6 +750,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
if(selected != null)
{
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
refreshLocation();
}
});
}
@@ -732,6 +758,111 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
return mCountyComboBox;
}
/**
* Row that shows the user's location and a button to change it
*/
private HBox getLocationBox()
{
if(mLocationBox == null)
{
mLocationBox = new HBox();
mLocationBox.setAlignment(Pos.CENTER_LEFT);
mLocationBox.setSpacing(5.0);
Button setLocationButton = new Button("My Location:");
setLocationButton.setOnAction(event -> {
LocationDialog dialog = new LocationDialog(mRadioReference,
mUserPreferences.getRadioReferencePreference().getLocation(), getScene().getWindow());
//The map follows the location through the preference update
dialog.showAndWait().ifPresent(result -> {
mUserPreferences.getRadioReferencePreference().setLocation(result.location());
refreshLocation();
});
});
mLocationBox.getChildren().addAll(setLocationButton, getLocationText());
}
return mLocationBox;
}
private Label getLocationText()
{
if(mLocationText == null)
{
mLocationText = new Label();
mLocationText.setTooltip(new Tooltip("Your location is shown on the map and used to find nearby Radio " +
"Reference frequencies. When it isn't set, the center of the selected county is used."));
}
return mLocationText;
}
/**
* Updates the location text: the user's location, or else the preferred county fallback, which is looked up in
* the background.
*/
private void refreshLocation()
{
long request = mLocationRequest.incrementAndGet();
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
if(location != null)
{
getLocationText().setText(location.describe());
return;
}
String notRanked = "Not set";
if(mUserPreferences.getRadioReferencePreference().getPreferredCountyId() <= 0 ||
!mRadioReference.premiumAccountProperty().get())
{
getLocationText().setText(notRanked);
return;
}
getLocationText().setText("Not set");
ThreadPool.CACHED.execute(() -> {
String text;
try
{
UserLocation userLocation = mRadioReference.getUserLocation();
if(userLocation == null)
{
text = notRanked;
}
else if(userLocation.source() == UserLocation.Source.PREFERRED_COUNTY)
{
text = "Not set - using the center of " + userLocation.description();
}
else
{
//Set in the meantime - a newer refresh shows it
return;
}
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to look up the preferred county location - " + rre.getMessage());
text = "Not set - unable to look up the selected county";
}
final String locationText = text;
Platform.runLater(() -> {
//A newer refresh supersedes this one
if(mLocationRequest.get() == request)
{
getLocationText().setText(locationText);
}
});
});
}
/**
* Consumer interface method for receiving login credentials

View File

@@ -0,0 +1,614 @@
/*
* *****************************************************************************
* 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.radioreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelException;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.RadioReferenceService;
import io.github.dsheirer.rrapi.type.Site;
import io.github.dsheirer.rrapi.type.SystemInformation;
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.service.radioreference.SiteRecord;
import io.github.dsheirer.source.config.SourceConfigTuner;
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
import io.github.dsheirer.source.config.SourceConfiguration;
import io.github.dsheirer.util.ThreadPool;
import java.util.ArrayList;
import java.util.Collection;
import java.util.ConcurrentModificationException;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Playlist actions for radio reference frequencies and trunked system sites: find the playlist channel that already
* decodes a frequency or site, enable or disable it, or add a channel and start it. A frequency or site that already
* has a playlist channel with the same protocol isn't added again.
*
* The add, enable and disable actions run in the background and report errors to the caller's error handler, on the
* calling thread pool: the handler shows the message on its own UI thread.
*/
public class RadioReferencePlaylistActions
{
private static final Logger mLog = LoggerFactory.getLogger(RadioReferencePlaylistActions.class);
private final PlaylistManager mPlaylistManager;
/**
* Constructs an instance
* @param playlistManager for the channels, channel processing and the radio reference service
*/
public RadioReferencePlaylistActions(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
}
/**
* Decoder types that count as the same protocol when looking for an existing channel: P25 Phase 1 and Phase 2
* (a Phase 2 system's control channel is often decoded with the Phase 1 decoder), and the LTR variants.
*/
public static Set<DecoderType> getProtocolFamily(DecoderType decoderType)
{
if(decoderType == null)
{
return EnumSet.noneOf(DecoderType.class);
}
return switch(decoderType)
{
case P25_PHASE1, P25_PHASE2 -> EnumSet.of(DecoderType.P25_PHASE1, DecoderType.P25_PHASE2);
case LTR, LTR_NET, PASSPORT -> EnumSet.of(DecoderType.LTR, DecoderType.LTR_NET, DecoderType.PASSPORT);
default -> EnumSet.of(decoderType);
};
}
/**
* Decoder types for a site's control channel, from the site's radio reference protocol
* @return decoder types, or an empty set when sdrtrunk can't decode the site's control channel
*/
public static Set<DecoderType> getSiteDecoderTypes(SiteRecord site)
{
String protocol = site.protocol() != null ? site.protocol().toLowerCase() : "";
if(protocol.startsWith("project 25"))
{
return getProtocolFamily(DecoderType.P25_PHASE1);
}
else if(protocol.startsWith("dmr"))
{
return EnumSet.of(DecoderType.DMR);
}
else if(protocol.startsWith("nxdn"))
{
return EnumSet.of(DecoderType.NXDN);
}
else if(protocol.startsWith("ltr"))
{
return getProtocolFamily(DecoderType.LTR);
}
else if(protocol.startsWith("mpt"))
{
return EnumSet.of(DecoderType.MPT1327);
}
//Motorola Type II, EDACS and other control channels aren't supported
return EnumSet.noneOf(DecoderType.class);
}
/**
* Indicates if the site's system has no control channel, so each repeater has its own channel: LTR, where every
* repeater carries its own trunking data
*/
public static boolean isRepeaterSite(SiteRecord site)
{
return getSiteDecoderTypes(site).contains(DecoderType.LTR);
}
/**
* Indicates if sdrtrunk can decode the site: its control channel, which radio reference lists, or its repeaters
*/
public static boolean isSupported(SiteRecord site)
{
return getUnsupportedReason(site) == null;
}
/**
* Why sdrtrunk can't decode the site
* @return reason, or null when the site is supported
*/
public static String getUnsupportedReason(SiteRecord site)
{
if(getSiteDecoderTypes(site).isEmpty())
{
return (site.protocol() != null ? site.protocol() : "The protocol") + " isn't supported";
}
if(isRepeaterSite(site) ? site.channels().isEmpty() : getControlFrequencies(site).isEmpty())
{
return isRepeaterSite(site) ? "Radio Reference lists no repeaters" :
"Radio Reference lists no control channel";
}
return null;
}
/**
* Frequencies of all of the site's channels
*/
public static List<Long> getSiteFrequencies(SiteRecord site)
{
List<Long> frequencies = new ArrayList<>();
for(SiteRecord.SiteChannel channel: site.channels())
{
frequencies.add(channel.frequencyHz());
}
return frequencies;
}
/**
* Control and alternate control channel frequencies of the site, control channels first
*/
public static List<Long> getControlFrequencies(SiteRecord site)
{
List<Long> frequencies = new ArrayList<>();
for(String use: List.of("Control", "Alternate control"))
{
for(SiteRecord.SiteChannel channel: site.channels())
{
if(use.equals(channel.use()))
{
frequencies.add(channel.frequencyHz());
}
}
}
return frequencies;
}
/**
* Decoder type for a conventional candidate's radio reference mode, or null when sdrtrunk has no decoder for it
*/
public static DecoderType getDecoderType(FrequencyCandidate candidate)
{
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
return mode.hasDecoderType() ? mode.getDecoderType() : null;
}
/**
* Playlist channel, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder types
* @return channel, or null
*/
public Channel findChannel(Collection<Long> frequencies, Set<DecoderType> decoderTypes)
{
return findChannel(getChannels(), frequencies, decoderTypes);
}
/**
* Channel in the list, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder
* types
* @return channel, or null
*/
static Channel findChannel(List<Channel> channels, Collection<Long> frequencies, Set<DecoderType> decoderTypes)
{
if(frequencies.isEmpty() || decoderTypes.isEmpty())
{
return null;
}
for(Channel channel: channels)
{
if(channel.isTrafficChannel() || channel.getDecodeConfiguration() == null ||
!decoderTypes.contains(channel.getDecodeConfiguration().getDecoderType()))
{
continue;
}
for(long frequency: getFrequencies(channel.getSourceConfiguration()))
{
if(frequencies.contains(frequency))
{
return channel;
}
}
}
return null;
}
/**
* Playlist channel that decodes the site, tuned to one of its control channels
* @return channel, or null
*/
public Channel findSiteChannel(SiteRecord site)
{
return findChannel(getControlFrequencies(site), getSiteDecoderTypes(site));
}
/**
* Playlist channels that decode the site: its control channel, or each of its repeaters that has a channel
*/
public List<Channel> findSiteChannels(SiteRecord site)
{
List<Channel> channels = new ArrayList<>();
if(isRepeaterSite(site))
{
List<Channel> playlist = getChannels();
for(long frequency: getSiteFrequencies(site))
{
Channel channel = findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site));
if(channel != null && !channels.contains(channel))
{
channels.add(channel);
}
}
}
else
{
Channel channel = findSiteChannel(site);
if(channel != null)
{
channels.add(channel);
}
}
return channels;
}
/**
* Frequencies of a repeater site that don't have a playlist channel yet
*/
public List<Long> getMissingRepeaterFrequencies(SiteRecord site)
{
List<Long> missing = new ArrayList<>();
List<Channel> playlist = getChannels();
for(long frequency: getSiteFrequencies(site))
{
if(findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site)) == null)
{
missing.add(frequency);
}
}
return missing;
}
/**
* Starts the channels that aren't decoding, in the background
*/
public void enableAll(List<Channel> channels, Consumer<String> onError)
{
for(Channel channel: channels)
{
if(!channel.isProcessing())
{
enable(channel, onError);
}
}
}
/**
* Stops the channels that are decoding, in the background
*/
public void disableAll(List<Channel> channels, Consumer<String> onError)
{
for(Channel channel: channels)
{
if(channel.isProcessing())
{
disable(channel, onError);
}
}
}
/**
* Playlist channel that decodes the conventional frequency with the same protocol
* @return channel, or null
*/
public Channel findConventionalChannel(FrequencyCandidate candidate)
{
return findChannel(List.of(candidate.frequencyHz()), getProtocolFamily(getDecoderType(candidate)));
}
/**
* Starts decoding the playlist channel, in the background
*/
public void enable(Channel channel, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
try
{
if(!channel.isProcessing())
{
mPlaylistManager.getChannelProcessingManager().start(channel);
}
}
catch(ChannelException ce)
{
mLog.error("Error starting channel [" + channel.getName() + "] - " + ce.getMessage());
onError.accept("Can't start " + channel.getShortTitle() + ": " + ce.getMessage());
}
});
}
/**
* Stops decoding the playlist channel, in the background
*/
public void disable(Channel channel, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
try
{
mPlaylistManager.getChannelProcessingManager().stop(channel);
}
catch(ChannelException ce)
{
mLog.error("Error stopping channel [" + channel.getName() + "] - " + ce.getMessage());
onError.accept("Can't stop " + channel.getShortTitle() + ": " + ce.getMessage());
}
});
}
/**
* Radio reference details of a site, for creating its channels
*/
private record SiteDetails(RadioReferenceDecoder decoder, io.github.dsheirer.rrapi.type.System system,
SystemInformation systemInformation, Site site)
{
}
/**
* Fetches the site's system details from radio reference. Call from a background thread.
* @return details, or null after reporting the error
*/
private SiteDetails getSiteDetails(SiteRecord siteRecord, Consumer<String> onError)
{
try
{
RadioReference radioReference = mPlaylistManager.getRadioReference();
RadioReferenceService service = radioReference.getService();
SystemInformation systemInformation = service.getSystemInformation(siteRecord.systemId());
Site site = null;
for(Site candidate: service.getSites(siteRecord.systemId()))
{
if(candidate.getSiteId() == siteRecord.siteId())
{
site = candidate;
}
}
if(systemInformation == null || site == null)
{
onError.accept("Radio Reference didn't provide the details of " + siteRecord.systemName() + " site " +
siteRecord.siteNumber());
return null;
}
RadioReferenceDecoder decoder = new RadioReferenceDecoder(radioReference.getUserPreferences(),
service.getTypesMap(), service.getFlavorsMap(), service.getVoicesMap(), service.getTagsMap());
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
system.setSystemId(siteRecord.systemId());
system.setName(systemInformation.getName() != null ? systemInformation.getName() : siteRecord.systemName());
system.setTypeId(systemInformation.getTypeId());
system.setFlavorId(systemInformation.getFlavorId());
system.setVoiceId(systemInformation.getVoiceId());
return new SiteDetails(decoder, system, systemInformation, site);
}
catch(RadioReferenceException rre)
{
mLog.error("Error getting radio reference site details - " + rre.getMessage());
onError.accept("Can't get the site details from Radio Reference: " + rre.getMessage());
return null;
}
}
/**
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels.
* A site with a control channel gets one channel for its control channels. A repeater site (LTR) gets a channel
* for each repeater that doesn't have one yet, and all of its channels are started. Fetches the system details
* from radio reference in the background.
*/
public void addSite(SiteRecord siteRecord, Consumer<String> onError)
{
addSite(siteRecord, null, onError);
}
/**
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels, like
* {@link #addSite(SiteRecord, Consumer)}. A new control channel starts on the first frequency when it's one of the
* site's control or alternate control channels.
* @param firstFrequency to start the new control channel on, e.g. the frequency the user picked, or null
*/
public void addSite(SiteRecord siteRecord, Long firstFrequency, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
SiteDetails details = getSiteDetails(siteRecord, onError);
if(details == null)
{
return;
}
if(isRepeaterSite(siteRecord))
{
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
details.systemInformation(), details.site(), getMissingRepeaterFrequencies(siteRecord));
List<Channel> existing = findSiteChannels(siteRecord);
if(channels.isEmpty() && existing.isEmpty())
{
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + " site " +
siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" + siteRecord.protocol() +
")" : ""));
return;
}
enableAll(existing, onError);
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
Platform.runLater(() -> {
for(Channel channel: channels)
{
mPlaylistManager.getChannelModel().addChannel(channel);
enable(channel, onError);
}
});
return;
}
Channel channel = RadioReferenceChannelFactory.trunkedSite(details.decoder(), details.system(),
details.systemInformation(), details.site(), firstFrequency);
if(channel == null)
{
onError.accept("sdrtrunk can't decode the control channel of " + siteRecord.systemName() +
" site " + siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" +
siteRecord.protocol() + ")" : ""));
return;
}
addAndStart(channel, onError);
});
}
/**
* Adds a playlist channel for one repeater of a repeater site (LTR) and starts it, or starts its existing channel
*/
public void addRepeater(SiteRecord siteRecord, long frequency, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
SiteDetails details = getSiteDetails(siteRecord, onError);
if(details == null)
{
return;
}
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
details.systemInformation(), details.site(), List.of(frequency));
if(channels.isEmpty())
{
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + (siteRecord.protocol() != null ?
" (" + siteRecord.protocol() + ")" : ""));
return;
}
addAndStart(channels.get(0), onError);
});
}
/**
* Adds a playlist channel for the conventional frequency and starts it, or starts the existing channel
*/
public void addConventional(FrequencyCandidate candidate, Consumer<String> onError)
{
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
String[] source = candidate.source() != null ? candidate.source().split(" › ") : new String[0];
//The source is owner, category and subcategory: the channel's system is the owner, the site the subcategory
String system = source.length > 0 ? source[0] : null;
String site = source.length > 2 ? source[2] : source.length > 1 ? source[1] : null;
String name = candidate.alphaTag() != null ? candidate.alphaTag() : candidate.title();
Channel channel = RadioReferenceChannelFactory.conventional(mode, candidate.frequencyHz(), system, site, name);
if(channel == null)
{
onError.accept("sdrtrunk can't decode " + (candidate.mode() != null ? candidate.mode() : "the mode of") +
" " + candidate.title());
return;
}
addAndStart(channel, onError);
}
/**
* Adds the channel to the playlist and starts it, unless the playlist already has a channel for its frequencies
* and protocol, which is started instead
*/
private void addAndStart(Channel channel, Consumer<String> onError)
{
Channel existing = findChannel(getFrequencies(channel.getSourceConfiguration()),
getProtocolFamily(channel.getDecodeConfiguration().getDecoderType()));
if(existing != null)
{
enable(existing, onError);
return;
}
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
Platform.runLater(() -> {
mPlaylistManager.getChannelModel().addChannel(channel);
enable(channel, onError);
});
}
/**
* Copy of the playlist channels, safe to read from any thread
*/
private List<Channel> getChannels()
{
for(int attempt = 0; attempt < 3; attempt++)
{
try
{
return new ArrayList<>(mPlaylistManager.getChannelModel().getChannels());
}
catch(ConcurrentModificationException e)
{
//The playlist changed while copying - try again
}
}
return new ArrayList<>();
}
/**
* Tuned frequencies of a channel source
*/
public static List<Long> getFrequencies(SourceConfiguration sourceConfiguration)
{
if(sourceConfiguration instanceof SourceConfigTuner tuner)
{
return List.of(tuner.getFrequency());
}
else if(sourceConfiguration instanceof SourceConfigTunerMultipleFrequency multiple)
{
return new ArrayList<>(multiple.getFrequencies());
}
return List.of();
}
}

View File

@@ -247,118 +247,7 @@ public class SiteEditor extends GridPane
*/
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
{
if(decoderType == null)
{
return null;
}
switch(decoderType)
{
case DMR:
DecodeConfigDMR dmr = new DecodeConfigDMR();
List<TimeslotFrequency> timeslotFrequencies = mRadioReferenceDecoder
.getTimeslotFrequencies(systemInformation, site);
dmr.setTimeslotMap(timeslotFrequencies);
return dmr;
case NXDN:
Flavor flavor = mRadioReferenceDecoder.getFlavor(systemInformation);
TransmissionMode mode = TransmissionMode.M4800;
/**
* 9600 bps Flavors
* ----------------------------
* 29 - NEXEDGE 9600
* 45 - Conventional Networked
*
* 4800 bps Flavors
* ---------------------------
* 11 - Narrowband Networked
* 42 - NEXEDGE 4800
* 44 - Icom IDAS Type C
*
* Type-D (also 4800 bps) Flavors
* ---------------------------
* 36 - Icom IDAS Type D
* 47 - Kenwood Type D
*/
mode = switch(flavor.getName())
{
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
default -> TransmissionMode.M4800;
};
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
List<ChannelFrequency> channelMap = mRadioReferenceDecoder.getChannelMap(systemInformation, site);
nxdn.setChannelMap(channelMap);
return nxdn;
case P25_PHASE1:
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
if(mRadioReferenceDecoder.isLSM(site))
{
if(p1config instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
}
}
return p1config;
case P25_PHASE2:
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
int nac = 0;
if(site != null && site.getNac() != null)
{
try
{
nac = Integer.parseInt(site.getNac(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
int wacn = 0;
int system = 0;
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
!systemInformation.getRadioNetworks().isEmpty())
{
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
if(radioNetwork.getWacn() != null)
{
try
{
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
if(radioNetwork.getSystemId() != null)
{
try
{
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
}
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
return config;
default:
return DecoderFactory.getDecodeConfiguration(decoderType);
}
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation);
}
/**
@@ -621,7 +510,7 @@ public class SiteEditor extends GridPane
if(controls.size() == 1)
{
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
channel.setSourceConfiguration(sourceConfigTuner);
}
else
@@ -630,7 +519,7 @@ public class SiteEditor extends GridPane
for(SiteFrequency control: controls)
{
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
sourceConfig.addFrequency(control.getFrequencyHz());
}
channel.setSourceConfiguration(sourceConfig);
@@ -856,7 +745,7 @@ public class SiteEditor extends GridPane
private static long getFrequency(SiteFrequency siteFrequency)
{
return (long)(siteFrequency.getFrequency() * 1E6);
return siteFrequency.getFrequencyHz();
}
/**

View File

@@ -139,8 +139,9 @@ public class SystemEditor extends VBox
if(systems != null && !systems.isEmpty())
{
Collections.sort(systems, new SystemComparator());
//The systems list belongs to a cached state or county info shared with other callers, so sort the items
getSystemComboBox().getItems().addAll(systems);
getSystemComboBox().getItems().sort(new SystemComparator());
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
@@ -302,7 +303,16 @@ public class SystemEditor extends VBox
//have a county ID of 99,999 as observed from API (undocumented).
if(countyId > 0 && countyId != 99999)
{
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
//The county name is only for display, so a failed lookup shouldn't fail the system load
try
{
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to retrieve county [" + countyId + "] for site [" +
site.getSiteNumber() + "] of system [" + system.getSystemId() + "] - " + rre.getMessage());
}
}
enrichedSites.add(new EnrichedSite(site, countyInfo));

View File

@@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane
if(talkgroups != null && !talkgroups.isEmpty())
{
Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue));
//The talkgroup list is shared by the caching service and is read-only, so sort a copy
List<Talkgroup> sorted = new ArrayList<>(talkgroups);
sorted.sort(Comparator.comparingInt(Talkgroup::getDecimalValue));
for(Talkgroup talkgroup: talkgroups)
for(Talkgroup talkgroup: sorted)
{
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
}

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,235 @@
/*
* *****************************************************************************
* 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.power;
import java.text.DecimalFormat;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
/**
* Vertical bar meter for a decibel power level, with optional peak and squelch threshold lines and a value scale.
*
* Use this class on the JavaFX application thread.
*/
public class PowerMeter extends Region
{
public static final double DEFAULT_MINIMUM_POWER = -110.0d;
public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
private static final double BAR_WIDTH = 30;
private static final double PADDING = 3;
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final Color COLOR_BAR = Color.LIGHTGRAY;
private static final Color COLOR_THRESHOLD = Color.BLUE;
private static final Color COLOR_PEAK = Color.PINK;
private final Canvas mCanvas = new Canvas();
private final ObjectProperty<Paint> mForeground = new SimpleObjectProperty<>(Color.BLACK);
private final ObjectProperty<Font> mFont = new SimpleObjectProperty<>(Font.getDefault());
private final double mMinimumValue = DEFAULT_MINIMUM_POWER;
private final double mMaximumValue = DEFAULT_MAXIMUM_POWER;
private double mPeak = mMinimumValue;
private double mPower = mMinimumValue;
private double mSquelchThreshold = mMinimumValue;
private boolean mPeakVisible = false;
private boolean mSquelchThresholdVisible = false;
/**
* Constructs an instance.
*/
public PowerMeter()
{
getChildren().add(mCanvas);
setPrefSize(90, 100);
setMinSize(70, 60);
mForeground.addListener((observable, oldValue, newValue) -> draw());
mFont.addListener((observable, oldValue, newValue) -> draw());
}
/**
* Color for the meter outline and scale text. Bind it to a themed control's text color.
*/
public ObjectProperty<Paint> foregroundProperty()
{
return mForeground;
}
/**
* Font for the scale text.
*/
public ObjectProperty<Font> fontProperty()
{
return mFont;
}
@Override
protected void layoutChildren()
{
double width = snapSizeX(getWidth());
double height = snapSizeY(getHeight());
if(mCanvas.getWidth() != width || mCanvas.getHeight() != height)
{
mCanvas.setWidth(width);
mCanvas.setHeight(height);
draw();
}
}
/**
* Resets peak, power and squelch to minimums
*/
public void reset()
{
mPeak = mMinimumValue;
mPower = mMinimumValue;
mSquelchThreshold = mMinimumValue;
draw();
}
/**
* Sets the current power level constrained to the minimum and maximum values for this control.
* @param power (dB)
*/
public void setPower(double power)
{
mPower = Math.clamp(power, mMinimumValue, mMaximumValue);
draw();
}
/**
* Sets the peak value line
* @param peak (db)
*/
public void setPeak(double peak)
{
mPeak = peak;
draw();
}
/**
* Sets the squelch threshold value
* @param squelchThreshold (dB)
*/
public void setSquelchThreshold(double squelchThreshold)
{
mSquelchThreshold = squelchThreshold;
draw();
}
/**
* Sets the peak line visibility
*/
public void setPeakVisible(boolean peakVisible)
{
mPeakVisible = peakVisible;
draw();
}
/**
* Sets the visibility of the squelch threshold line
*/
public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
{
mSquelchThresholdVisible = squelchThresholdVisible;
draw();
}
private void draw()
{
double width = mCanvas.getWidth();
double height = mCanvas.getHeight();
GraphicsContext gc = mCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, width, height);
if(width <= 0 || height <= 0)
{
return;
}
double meterHeight = height - 1;
//Meter outline
gc.setStroke(mForeground.get());
gc.setLineWidth(1);
gc.strokeRect(0.5, 0.5, BAR_WIDTH, meterHeight - 0.5);
//Filled bar
gc.setFill(COLOR_BAR);
double top = getY(getPercent(mPower), meterHeight);
gc.fillRect(PADDING, top, BAR_WIDTH - 2 * PADDING + 1, meterHeight - PADDING - top);
if(mSquelchThresholdVisible && isValidValue(mSquelchThreshold))
{
gc.setStroke(COLOR_THRESHOLD);
drawLine(gc, getY(getPercent(mSquelchThreshold), meterHeight));
}
if(mPeakVisible && isValidValue(mPeak))
{
gc.setStroke(COLOR_PEAK);
drawLine(gc, getY(getPercent(mPeak), meterHeight));
}
//Scale, with the number of labels adapted to the height of the control
Font font = mFont.get() != null ? mFont.get() : Font.getDefault();
gc.setFont(font);
gc.setFill(mForeground.get());
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.CENTER);
double extent = mMaximumValue - mMinimumValue;
double labelCount = Math.max(1, height / (font.getSize() * 2.5));
double interval = extent / labelCount;
for(double value = mMaximumValue; value > mMinimumValue; value -= interval)
{
double y = Math.clamp(getY(getPercent(value), meterHeight), font.getSize() / 2, height - font.getSize() / 2);
gc.fillText(DECIMAL_FORMAT.format(value), BAR_WIDTH + PADDING * 2, y);
}
}
private void drawLine(GraphicsContext gc, double y)
{
double snapped = Math.floor(y) + 0.5;
gc.strokeLine(0.5, snapped, BAR_WIDTH + 0.5, snapped);
}
private double getY(double percent, double meterHeight)
{
double fillHeight = meterHeight - 2 * PADDING;
return PADDING + fillHeight * (1.0 - percent);
}
private double getPercent(double value)
{
return (value - mMinimumValue) / (mMaximumValue - mMinimumValue);
}
private boolean isValidValue(double value)
{
return mMinimumValue <= value && value <= mMaximumValue;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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
@@ -21,99 +21,109 @@ package io.github.dsheirer.gui.power;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.squelch.ISquelchConfiguration;
import io.github.dsheirer.gui.control.DbPowerMeter;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.source.SourceEvent;
import java.awt.EventQueue;
import java.text.DecimalFormat;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import net.miginfocom.swing.MigLayout;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import jiconfont.javafx.IconNode;
/**
* Swing view for displaying signal power measurements with integrated squelch control.
* Signal power meter with squelch threshold controls for the AM decoder.
*/
public class SignalPowerView extends JPanel
public class SignalPowerView extends HBox
{
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
private static final String NOT_AVAILABLE = "Not Available";
private final DbPowerMeter mPowerMeter = new DbPowerMeter();
private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER);
private final JLabel mPowerLabel;
private final JLabel mPeakLabel;
private final JLabel mSquelchLabel;
private final JLabel mSquelchValueLabel;
private final JButton mSquelchUpButton;
private final JButton mSquelchDownButton;
private final JCheckBox mSquelchAutoTrackCheckBox;
private double mSquelchThreshold;
private final PowerMeter mPowerMeter = new PowerMeter();
private final PeakMonitor mPeakMonitor = new PeakMonitor(PowerMeter.DEFAULT_MINIMUM_POWER);
private final Label mPowerLabel = new Label("0");
private final Label mPeakLabel = new Label("0");
private final Label mSquelchLabel = new Label("Squelch:");
private final Label mSquelchValueLabel = new Label(NOT_AVAILABLE);
private final Button mSquelchUpButton = new Button();
private final Button mSquelchDownButton = new Button();
private final CheckBox mSquelchAutoTrackCheckBox = new CheckBox("Auto Track");
private final AtomicBoolean mPowerUpdatePending = new AtomicBoolean();
private volatile double mPower;
private volatile double mPeak;
private volatile double mSquelchThreshold;
private final PlaylistManager mPlaylistManager;
private ProcessingChain mProcessingChain;
private volatile ProcessingChain mProcessingChain;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for saving squelch changes to the channel configuration
*/
public SignalPowerView(PlaylistManager playlistManager)
{
super(10);
mPlaylistManager = playlistManager;
setPadding(new Insets(5));
setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]"));
Label meterTitle = new Label("Power (dB)");
mPowerMeter.setPeakVisible(true);
mPowerMeter.setSquelchThresholdVisible(true);
add(mPowerMeter);
//Follow the themed label text color and font so that the meter is readable in light and dark themes
mPowerMeter.foregroundProperty().bind(meterTitle.textFillProperty());
mPowerMeter.fontProperty().bind(meterTitle.fontProperty());
VBox.setVgrow(mPowerMeter, Priority.ALWAYS);
VBox meterBox = new VBox(3, meterTitle, mPowerMeter);
meterBox.setMinWidth(90);
JPanel valuePanel = new JPanel();
valuePanel.setLayout(new MigLayout("", "[right][left][][]", ""));
mPeakLabel.setTooltip(new Tooltip("Current peak power level in decibels."));
mPowerLabel.setTooltip(new Tooltip("Current Power level in decibels"));
mSquelchValueLabel.setTooltip(new Tooltip("Squelch threshold value in decibels"));
mPeakLabel = new JLabel("0");
mPeakLabel.setToolTipText("Current peak power level in decibels.");
valuePanel.add(new JLabel("Peak:"));
valuePanel.add(mPeakLabel, "wrap");
mSquelchUpButton.setGraphic(icon(FontAwesome.ANGLE_UP));
mSquelchUpButton.setTooltip(new Tooltip("Increases the squelch threshold value"));
mSquelchUpButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
mPowerLabel = new JLabel("0");
mPowerLabel.setToolTipText("Current Power level in decibels");
valuePanel.add(new JLabel("Power:"));
valuePanel.add(mPowerLabel, "wrap");
mSquelchDownButton.setGraphic(icon(FontAwesome.ANGLE_DOWN));
mSquelchDownButton.setTooltip(new Tooltip("Decreases the squelch threshold value."));
mSquelchDownButton.setOnAction(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
mSquelchLabel = new JLabel("Squelch:");
mSquelchLabel.setEnabled(false);
valuePanel.add(mSquelchLabel);
mSquelchValueLabel = new JLabel(NOT_AVAILABLE);
mSquelchValueLabel.setToolTipText("Squelch threshold value in decibels");
mSquelchValueLabel.setEnabled(false);
valuePanel.add(mSquelchValueLabel, "wrap");
IconFontSwing.register(FontAwesome.getIconFont());
Icon iconUp = IconFontSwing.buildIcon(FontAwesome.ANGLE_UP, 12);
mSquelchUpButton = new JButton(iconUp);
mSquelchUpButton.setToolTipText("Increases the squelch threshold value");
mSquelchUpButton.setEnabled(false);
mSquelchUpButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1)));
valuePanel.add(mSquelchUpButton);
Icon iconDown = IconFontSwing.buildIcon(FontAwesome.ANGLE_DOWN, 12);
mSquelchDownButton = new JButton(iconDown);
mSquelchDownButton.setToolTipText("Decreases the squelch threshold value.");
mSquelchDownButton.setEnabled(false);
mSquelchDownButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1)));
valuePanel.add(mSquelchDownButton, "wrap");
mSquelchAutoTrackCheckBox = new JCheckBox("Auto Track");
mSquelchAutoTrackCheckBox.setToolTipText("Enable or disable monitoring of the noise floor to auto-adjust the " +
"squelch threshold value maintaining a consistent level/buffer above the noise floor");
mSquelchAutoTrackCheckBox.setEnabled(false);
mSquelchAutoTrackCheckBox.addActionListener(e ->
{
mSquelchAutoTrackCheckBox.setTooltip(new Tooltip("Enable or disable monitoring of the noise floor to " +
"auto-adjust the squelch threshold value maintaining a consistent level/buffer above the noise floor"));
mSquelchAutoTrackCheckBox.setOnAction(e -> {
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
setConfigSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected());
});
valuePanel.add(mSquelchAutoTrackCheckBox, "span,left");
add(valuePanel);
GridPane values = new GridPane();
values.setHgap(5);
values.setVgap(5);
values.add(new Label("Peak:"), 0, 0);
values.add(mPeakLabel, 1, 0);
values.add(new Label("Power:"), 0, 1);
values.add(mPowerLabel, 1, 1);
values.add(mSquelchLabel, 0, 2);
values.add(mSquelchValueLabel, 1, 2);
values.add(new HBox(3, mSquelchUpButton, mSquelchDownButton), 0, 3, 2, 1);
values.add(mSquelchAutoTrackCheckBox, 0, 4, 2, 1);
values.setAlignment(Pos.TOP_LEFT);
getChildren().addAll(meterBox, values);
reset();
}
private IconNode icon(FontAwesome fontAwesome)
{
IconNode iconNode = new IconNode(fontAwesome);
iconNode.fillProperty().bind(mSquelchLabel.textFillProperty());
return iconNode;
}
/**
@@ -125,9 +135,11 @@ public class SignalPowerView extends JPanel
*/
private void setConfigSquelchThreshold(int threshold)
{
if(mProcessingChain != null)
ProcessingChain processingChain = mProcessingChain;
if(processingChain != null)
{
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(processingChain);
if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration)
{
@@ -154,15 +166,16 @@ public class SignalPowerView extends JPanel
private void broadcast(SourceEvent sourceEvent)
{
if(mProcessingChain != null)
ProcessingChain processingChain = mProcessingChain;
if(processingChain != null)
{
mProcessingChain.broadcast(sourceEvent);
processingChain.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.
* Resets controls when changing processing chain source. Call on the JavaFX application thread.
*/
private void reset()
{
@@ -170,44 +183,53 @@ public class SignalPowerView extends JPanel
mPowerMeter.reset();
mPeakLabel.setText("0");
mPowerLabel.setText("0");
mSquelchLabel.setEnabled(false);
mSquelchValueLabel.setText("Not Available");
mSquelchValueLabel.setEnabled(false);
mSquelchUpButton.setEnabled(false);
mSquelchDownButton.setEnabled(false);
mSquelchAutoTrackCheckBox.setEnabled(false);
mSquelchLabel.setDisable(true);
mSquelchValueLabel.setText(NOT_AVAILABLE);
mSquelchValueLabel.setDisable(true);
mSquelchUpButton.setDisable(true);
mSquelchDownButton.setDisable(true);
mSquelchAutoTrackCheckBox.setDisable(true);
mSquelchAutoTrackCheckBox.setSelected(false);
}
/**
* Processes power and squelch source events from the processing chain. Can be called from any thread.
*/
public void receive(SourceEvent sourceEvent)
{
switch(sourceEvent.getEvent())
{
case NOTIFICATION_CHANNEL_POWER ->
{
final double power = sourceEvent.getValue().doubleValue();
final double peak = mPeakMonitor.process(power);
mPower = sourceEvent.getValue().doubleValue();
mPeak = mPeakMonitor.process(mPower);
EventQueue.invokeLater(() -> {
mPowerMeter.setPower(power);
mPowerLabel.setText(DECIMAL_FORMAT.format(power));
mPowerMeter.setPeak(peak);
mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
});
//Power notifications are frequent, so combine the updates that arrive while one is pending
if(mPowerUpdatePending.compareAndSet(false, true))
{
Platform.runLater(() -> {
mPowerUpdatePending.set(false);
double power = mPower;
double peak = mPeak;
mPowerMeter.setPower(power);
mPowerLabel.setText(DECIMAL_FORMAT.format(power));
mPowerMeter.setPeak(peak);
mPeakLabel.setText(DECIMAL_FORMAT.format(peak));
});
}
}
case NOTIFICATION_SQUELCH_THRESHOLD ->
{
final double threshold = sourceEvent.getValue().doubleValue();
mSquelchThreshold = threshold;
EventQueue.invokeLater(() -> {
Platform.runLater(() -> {
mPowerMeter.setSquelchThreshold(threshold);
mSquelchLabel.setEnabled(true);
mSquelchValueLabel.setEnabled(true);
mSquelchLabel.setDisable(false);
mSquelchValueLabel.setDisable(false);
mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold));
mSquelchDownButton.setEnabled(true);
mSquelchUpButton.setEnabled(true);
mSquelchDownButton.setDisable(false);
mSquelchUpButton.setDisable(false);
});
}
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
@@ -215,16 +237,16 @@ public class SignalPowerView extends JPanel
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
{
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
EventQueue.invokeLater(() -> {
Platform.runLater(() -> {
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
mSquelchAutoTrackCheckBox.setEnabled(true);
mSquelchAutoTrackCheckBox.setDisable(false);
});
}
}
}
/**
* Sets the processing chain for this view
* Sets the processing chain for this view. Call on the JavaFX application thread.
*/
public void setProcessingChain(ProcessingChain processingChain)
{

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,90 @@
/*
* *****************************************************************************
* 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.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Text and placement of the cursor readout: the frequency under the mouse, and the level in the spectrum, in a box
* beside the mouse. The frequency layer draws the readout. A layer that replaces the readout with a larger callout
* (see {@link SpectralDisplayLayer#replacesCursorReadout}) uses the same text and placement, so the readout appears to
* grow into the callout.
*/
public final class CursorReadout
{
/**
* Horizontal distance between the mouse and the box
*/
public static final double GAP_X = 8;
/**
* Vertical distance between the box and the mouse below it
*/
public static final double GAP_Y = 4;
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat LEVEL_FORMAT = new DecimalFormat("0.0");
private CursorReadout()
{
}
/**
* Readout lines: the frequency under the mouse, and the level when the mouse is over the spectrum plot
*/
public static List<String> lines(DisplayCursor cursor, RenderArea area, SpectralDisplayModel model)
{
List<String> lines = new ArrayList<>(2);
lines.add(FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz");
if(cursor.section() == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
{
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
lines.add(LEVEL_FORMAT.format(decibels) + " dBFS");
}
return lines;
}
/**
* Left edge of a box beside the mouse: to the right of the mouse, or to the left when it doesn't fit
*/
public static double x(DisplayCursor cursor, double width, RenderArea area)
{
double x = cursor.x() + GAP_X;
if(x + width > area.width())
{
x = cursor.x() - GAP_X - width;
}
return x;
}
/**
* Top edge of a box above the mouse, kept inside the plot
*/
public static double y(DisplayCursor cursor, double height, RenderArea area)
{
return Math.max(0, Math.min(area.plotHeight() - height, cursor.y() - height - GAP_Y));
}
}

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,174 @@
/*
* *****************************************************************************
* 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)
{
}
/**
* Indicates if this layer draws its own callout in place of the cursor readout for the mouse position, so the
* frequency layer skips the readout. The callout should start with the {@link CursorReadout} lines and use its
* placement. Called on the JavaFX application thread before the layers draw the section under the mouse.
* @param cursor mouse position
* @param area of the section under the mouse
*/
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
{
return false;
}
/**
* 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,366 @@
/*
* *****************************************************************************
* 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.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
import io.github.dsheirer.gui.spectrum.layer.RadioReferenceLayer;
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 RADIO_REFERENCE_HOVER = "radioreference.hover";
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
private final ChannelLayer mChannelLayer;
private final RadioReferenceLayer mRadioReferenceLayer;
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(new ChannelLayer(playlistManager != null ? playlistManager.getChannelModel() : null,
playlistManager != null ? playlistManager.getChannelProcessingManager() : null),
new RadioReferenceLayer(playlistManager != null && playlistManager.getRadioReference() != null ?
playlistManager.getRadioReference().getFrequencyIndex() : null,
playlistManager != null ? new RadioReferencePlaylistActions(playlistManager) : 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), new RadioReferenceLayer(null, null),
discoveredTunerModel, saveSettings);
}
private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer,
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
{
//The radio reference layer draws after the channel layer, so its card is on top
super(SETTINGS_PREFIX, saveSettings, channelLayer, radioReferenceLayer);
mChannelLayer = channelLayer;
mRadioReferenceLayer = radioReferenceLayer;
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));
mRadioReferenceLayer.hoverCandidatesProperty().set(properties.get(key(RADIO_REFERENCE_HOVER), true));
mRadioReferenceLayer.hoverCandidatesProperty().addListener((ob, ov, nv) -> save(RADIO_REFERENCE_HOVER, 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,801 @@
/*
* *****************************************************************************
* 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.ChannelException;
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 io.github.dsheirer.util.ThreadPool;
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.application.Platform;
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.Alert;
import javafx.scene.control.ButtonType;
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())
{
//Traffic channels are started and stopped by their decoder, not from the playlist
if(!channel.isTrafficChannel() && mChannelProcessingManager != null)
{
items.add(createEnableItem(channel));
}
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
items.add(item);
break;
}
}
}
return items;
}
/**
* Menu item that starts the channel when it's disabled, or stops it when it's enabled, in the background
*/
private MenuItem createEnableItem(Channel channel)
{
boolean processing = channel.isProcessing();
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
item.setOnAction(e -> ThreadPool.CACHED.execute(() -> {
try
{
if(processing)
{
mChannelProcessingManager.stop(channel);
}
else
{
mChannelProcessingManager.start(channel);
}
}
catch(ChannelException ce)
{
showError((processing ? "Can't stop " : "Can't start ") + channel.getShortTitle(), ce.getMessage());
}
}));
return item;
}
/**
* Shows an error alert over the display. Can be called from any thread.
*/
private void showError(String header, String message)
{
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setTitle("Channel");
alert.setHeaderText(header);
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
{
alert.initOwner(getSpectralDisplay().getScene().getWindow());
}
alert.show();
});
}
/**
* 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,298 @@
/*
* *****************************************************************************
* 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.CursorReadout;
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 java.util.List;
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 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());
//Another layer may show the readout lines in a larger callout
if(cursor.section() != section || getSpectralDisplay().isCursorReadoutReplaced(cursor, area))
{
return;
}
List<String> lines = CursorReadout.lines(cursor, area, model);
double textWidth = 0;
for(String line: lines)
{
textWidth = Math.max(textWidth, measure(line));
}
double boxWidth = textWidth + 8;
double boxHeight = (mTextHeight * lines.size()) + 4;
double boxX = CursorReadout.x(cursor, boxWidth, area);
double boxY = CursorReadout.y(cursor, boxHeight, area);
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);
for(int index = 0; index < lines.size(); index++)
{
gc.fillText(lines.get(index), boxX + 4, boxY + 2 + (index * 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

@@ -0,0 +1,747 @@
/*
* *****************************************************************************
* 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.gui.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.service.radioreference.SiteRecord;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.util.HashSet;
import java.util.Set;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.MenuItem;
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
import io.github.dsheirer.gui.spectrum.CursorReadout;
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.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.service.radioreference.DistanceFormat;
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.animation.PauseTransition;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
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;
import javafx.util.Duration;
/**
* Radio reference candidates layer. When the mouse rests over the spectrum or the waterfall, shows a card next to the
* mouse with the radio reference frequencies near the frequency under the mouse, nearest to the user's location first.
*
* The candidates come from the radio reference frequency index, which needs a premium account and the user's location.
* Looking up a frequency doesn't call the web service.
*/
public class RadioReferenceLayer extends SpectralDisplayLayer
{
private static final Duration HOVER_DELAY = Duration.millis(400);
private static final Font FONT = Font.font(11);
private static final Font TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final long MIN_TOLERANCE_HZ = 3_000;
private static final double TOLERANCE_PIXELS = 3.0;
private static final int MAX_CANDIDATES = 6;
private static final double MAX_TEXT_WIDTH = 380;
private static final double PADDING = 6;
private static final double GAP = 5;
//Space beside the text and below the readout lines, as in the cursor readout, so the readout lines stay in place
private static final double PADDING_X = 4;
//Space kept between the card and the edges of the section
private static final double EDGE_MARGIN = 2;
private static final double READOUT_PADDING = 2;
private static final String HEADER = "Radio Reference";
private final RadioReferenceFrequencyIndex mIndex;
private final RadioReferencePlaylistActions mActions;
private final BooleanProperty mHoverCandidates = new SimpleBooleanProperty(true);
private final Text mMeasure = new Text();
private final double mTextHeight;
private PauseTransition mHoverTimer;
private DisplaySection mHoverSection;
private double mHoverX = Double.NaN;
private double mHoverY = Double.NaN;
private boolean mHoverReady;
private long mResultsFrequency = Long.MIN_VALUE;
private long mResultsTolerance;
private List<RankedCandidate> mResults = Collections.emptyList();
private Card mCard;
/**
* Candidate text of the card, measured once per lookup
* @param entries lines of each candidate: the title, then the details
* @param more line, or null
* @param textWidth of the widest line
* @param lineCount of the header, candidate and more lines
* @param maxTextWidth that the lines were shortened to
* @param maxHeight that the card was limited to
* @param readoutLines that the card was sized for
*/
private record Card(List<List<String>> entries, String more, double textWidth, int lineCount,
double maxTextWidth, double maxHeight, int readoutLines)
{
}
/**
* Constructs an instance.
* @param index of the radio reference frequencies near the user, or null for a layer that shows nothing
* @param actions to add candidates to the playlist or enable their channels, or null for no context menu items
*/
public RadioReferenceLayer(RadioReferenceFrequencyIndex index, RadioReferencePlaylistActions actions)
{
mIndex = index;
mActions = actions;
mMeasure.setFont(FONT);
mMeasure.setText("Ag");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
mHoverCandidates.addListener((ob, ov, nv) -> requestRender());
}
/**
* Shows the card of radio reference candidates when the mouse rests over the display
*/
public BooleanProperty hoverCandidatesProperty()
{
return mHoverCandidates;
}
@Override
protected void attached()
{
if(mIndex != null)
{
mIndex.start();
}
}
@Override
public void dispose()
{
if(mHoverTimer != null)
{
mHoverTimer.stop();
}
}
@Override
public String getConfigurationTitle()
{
return "Radio Reference";
}
@Override
public List<ConfigurationRow> getConfigurationRows()
{
if(mIndex == null)
{
return Collections.emptyList();
}
ConfigurationRow hover = ConfigurationRow.toggle("Hover Candidates", mHoverCandidates);
Tooltip.install(hover.control(), new Tooltip("When the mouse rests over a signal, show the Radio Reference " +
"frequencies near it, nearest to your location first"));
Label status = new Label();
status.textProperty().bind(mIndex.statusProperty());
status.setWrapText(true);
status.setMaxWidth(220);
return List.of(hover, new ConfigurationRow("Nearby", status));
}
/**
* Context menu items for the candidates at the frequency, in the card's order: add a candidate to the playlist and
* start it, or enable or disable the playlist channel that already decodes it. One item per site, and per
* frequency and protocol, so an item never adds a duplicate channel.
*/
@Override
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
{
if(mIndex == null || mActions == null)
{
return Collections.emptyList();
}
//The card's candidates when the mouse hasn't moved since the card was shown
List<RankedCandidate> candidates = frequency == mResultsFrequency ? mResults :
mIndex.lookup(frequency, mResultsTolerance > 0 ? mResultsTolerance : MIN_TOLERANCE_HZ);
List<MenuItem> items = new ArrayList<>();
Set<String> offered = new HashSet<>();
for(RankedCandidate ranked: candidates)
{
if(offered.size() >= MAX_CANDIDATES)
{
break;
}
FrequencyCandidate candidate = ranked.candidate();
if(candidate.isSiteFrequency())
{
SiteRecord site = mIndex.getSite(candidate.systemId(), candidate.siteId());
//A repeater site (LTR) has a channel per repeater, otherwise one channel for the site
String key = site == null ? null : RadioReferencePlaylistActions.isRepeaterSite(site) ?
"repeater:" + candidate.frequencyHz() : "site:" + site.systemId() + ":" + site.siteId();
if(site != null && offered.add(key))
{
addSiteItem(items, site, candidate, frequency);
}
}
else
{
DecoderType decoderType = RadioReferencePlaylistActions.getDecoderType(candidate);
Set<DecoderType> family = RadioReferencePlaylistActions.getProtocolFamily(decoderType);
String protocol = family.isEmpty() ? String.valueOf(candidate.mode()) : family.iterator().next().name();
if(offered.add("frequency:" + candidate.frequencyHz() + ":" + protocol))
{
addConventionalItem(items, candidate, decoderType, frequency);
}
}
}
if(!items.isEmpty())
{
MenuItem header = new MenuItem("Radio Reference");
header.setDisable(true);
items.add(0, header);
}
return items;
}
private void addSiteItem(List<MenuItem> items, SiteRecord site, FrequencyCandidate candidate, long frequency)
{
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
(site.description() != null ? " " + site.description() : "");
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
if(unsupported != null)
{
MenuItem item = new MenuItem(label + " \u2013 " + unsupported);
item.setDisable(true);
items.add(item);
return;
}
if(RadioReferencePlaylistActions.isRepeaterSite(site))
{
addRepeaterItem(items, site, candidate.frequencyHz(), label, frequency);
return;
}
Channel existing = mActions.findSiteChannel(site);
if(existing != null)
{
//The channel layer offers the channels at the clicked frequency
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
}
else
{
MenuItem item = new MenuItem("Add and Start: " + label);
//Start on the right-clicked frequency when it's a control or alternate control channel
item.setOnAction(event -> mActions.addSite(site, candidate.frequencyHz(), this::showError));
items.add(item);
}
}
/**
* Menu item for one repeater of a repeater site (LTR): add and start it, or enable or disable its channel
*/
private void addRepeaterItem(List<MenuItem> items, SiteRecord site, long repeaterFrequency, String label,
long frequency)
{
Channel existing = mActions.findChannel(List.of(repeaterFrequency),
RadioReferencePlaylistActions.getSiteDecoderTypes(site));
if(existing != null)
{
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
return;
}
String lcn = site.channels().stream().filter(channel -> channel.frequencyHz() == repeaterFrequency &&
channel.logicalChannelNumber() > 0).map(channel -> " LCN " + channel.logicalChannelNumber())
.findFirst().orElse("");
MenuItem item = new MenuItem("Add and Start: " + label + lcn + " (" +
FREQUENCY_FORMAT.format(repeaterFrequency / 1E6) + " MHz)");
item.setOnAction(event -> mActions.addRepeater(site, repeaterFrequency, this::showError));
items.add(item);
}
private void addConventionalItem(List<MenuItem> items, FrequencyCandidate candidate, DecoderType decoderType,
long frequency)
{
String label = candidate.title() + " (" + FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz" +
(candidate.mode() != null ? " " + candidate.mode() : "") + ")";
if(decoderType == null)
{
MenuItem item = new MenuItem(label + " \u2013 mode isn't supported");
item.setDisable(true);
items.add(item);
return;
}
Channel existing = mActions.findConventionalChannel(candidate);
if(existing != null)
{
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
}
else
{
MenuItem item = new MenuItem("Add and Start: " + label);
item.setOnAction(event -> mActions.addConventional(candidate, this::showError));
items.add(item);
}
}
/**
* Menu item that enables the playlist channel when it's disabled, or disables it when it's enabled
*/
private MenuItem createEnableItem(Channel channel)
{
boolean processing = channel.isProcessing();
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
item.setOnAction(event -> {
if(processing)
{
mActions.disable(channel, this::showError);
}
else
{
mActions.enable(channel, this::showError);
}
});
return item;
}
/**
* Indicates if one of the channel's tuner channels includes the frequency
*/
private static boolean covers(Channel channel, long frequency)
{
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
frequency <= tunerChannel.getMaxFrequency())
{
return true;
}
}
return false;
}
/**
* Shows an error alert over the display. Can be called from any thread.
*/
private void showError(String message)
{
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setTitle("Radio Reference");
alert.setHeaderText("Can't add or start the channel");
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
{
alert.initOwner(getSpectralDisplay().getScene().getWindow());
}
alert.show();
});
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
render(gc, area, DisplaySection.SPECTRUM);
}
@Override
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
render(gc, area, DisplaySection.WATERFALL);
}
/**
* Draws the card in the section under the mouse once the mouse has rested for the hover delay
*/
private void render(GraphicsContext gc, RenderArea area, DisplaySection section)
{
DisplayCursor cursor = getSpectralDisplay() != null ? getSpectralDisplay().getDisplayCursor() : null;
trackHover(cursor);
if(cursor != null && cursor.section() == section && replacesCursorReadout(cursor, area))
{
drawCard(gc, area, cursor, getCandidates(cursor.frequency(), getTolerance(getModel(), area)));
}
}
/**
* The card replaces the cursor readout while it's shown: it ends with the readout lines, in the readout's place.
*/
@Override
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
{
SpectralDisplayModel model = getModel();
//Only for the position the mouse rested at - the readout comes back as soon as the mouse moves
if(mIndex == null || !mHoverCandidates.get() || !mHoverReady || model == null || !model.hasSource() ||
cursor == null || cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY ||
area.width() <= 0)
{
return false;
}
return !getCandidates(cursor.frequency(), getTolerance(model, area)).isEmpty();
}
/**
* Frequency tolerance for the candidates: a few pixels or a DFT bin at the current zoom, but at least 3 kHz
*/
private static long getTolerance(SpectralDisplayModel model, RenderArea area)
{
double hertzPerPixel = model.getDisplayBandwidth() / area.width();
return Math.max(MIN_TOLERANCE_HZ, Math.round(Math.max(TOLERANCE_PIXELS * hertzPerPixel, model.getBinWidth())));
}
/**
* Restarts the hover delay each time the mouse moves, and hides the card until it passes
*/
private void trackHover(DisplayCursor cursor)
{
if(cursor == null)
{
if(mHoverSection != null)
{
mHoverSection = null;
mHoverReady = false;
getHoverTimer().stop();
}
return;
}
if(cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY)
{
mHoverSection = cursor.section();
mHoverX = cursor.x();
mHoverY = cursor.y();
mHoverReady = false;
getHoverTimer().playFromStart();
}
}
private PauseTransition getHoverTimer()
{
if(mHoverTimer == null)
{
mHoverTimer = new PauseTransition(HOVER_DELAY);
mHoverTimer.setOnFinished(event -> {
mHoverReady = true;
requestRender();
});
}
return mHoverTimer;
}
/**
* Candidates for the frequency, looked up again only when the frequency or the tolerance changes
*/
private List<RankedCandidate> getCandidates(long frequency, long tolerance)
{
if(frequency != mResultsFrequency || tolerance != mResultsTolerance)
{
mResults = mIndex.lookup(frequency, tolerance);
mResultsFrequency = frequency;
mResultsTolerance = tolerance;
mCard = null;
}
return mResults;
}
/**
* Draws the card in place of the cursor readout: beside the mouse and above it, placed like the readout, with the
* candidates above the readout lines so the frequency and level stay where the readout showed them. The card
* fits the larger side of the mouse and the height of the section, showing as many candidates as fit.
*/
private void drawCard(GraphicsContext gc, RenderArea area, DisplayCursor cursor, List<RankedCandidate> candidates)
{
//The level line changes with the mouse height while the candidates don't
List<String> readout = CursorReadout.lines(cursor, area, getModel());
double readoutWidth = 0;
for(String line: readout)
{
readoutWidth = Math.max(readoutWidth, measure(line, FONT));
}
double room = Math.max(area.width() - cursor.x(), cursor.x()) - CursorReadout.GAP_X - EDGE_MARGIN;
double maxTextWidth = Math.max(readoutWidth, Math.min(MAX_TEXT_WIDTH, room - (PADDING_X * 2)));
//Above the mouse, so the readout lines stay where the readout showed them, when a candidate fits there
double above = cursor.y() - CursorReadout.GAP_Y;
double maxHeight = above >= cardHeight(5, 1, true, readout.size()) ? above : area.plotHeight() - EDGE_MARGIN;
if(mCard == null || mCard.maxTextWidth() != maxTextWidth || mCard.maxHeight() != maxHeight ||
mCard.readoutLines() != readout.size())
{
mCard = createCard(candidates, maxTextWidth, maxHeight, readout.size());
}
Card card = mCard;
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
double width = Math.max(card.textWidth(), readoutWidth) + (PADDING_X * 2);
double height = cardHeight(card.lineCount(), card.entries().size(), card.more() != null, readout.size());
double x = Math.max(0, CursorReadout.x(cursor, width, area));
double y = CursorReadout.y(cursor, height, area);
gc.setFill(theme.getBackground(0.92));
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 textX = x + PADDING_X;
double lineY = y + PADDING;
gc.setFill(theme.getCursor());
gc.setFont(TITLE_FONT);
gc.fillText(HEADER, textX, lineY);
lineY += mTextHeight + GAP;
for(List<String> lines: card.entries())
{
gc.setFill(theme.getCursor());
gc.setFont(TITLE_FONT);
gc.fillText(lines.get(0), textX, lineY);
lineY += mTextHeight;
gc.setFill(opaque(theme.getLine()));
gc.setFont(FONT);
for(int line = 1; line < lines.size(); line++)
{
gc.fillText(lines.get(line), textX, lineY);
lineY += mTextHeight;
}
lineY += GAP;
}
if(card.more() != null)
{
gc.setFill(opaque(theme.getLine()));
gc.setFont(FONT);
gc.fillText(card.more(), textX, lineY);
lineY += mTextHeight + GAP;
}
//The readout lines, separated from the candidates, in the readout's style
double separator = Math.floor(lineY - (GAP / 2.0)) + 0.5;
gc.setStroke(theme.getLine(0.5));
gc.strokeLine(textX, separator, x + width - PADDING_X, separator);
gc.setFill(theme.getCursor());
gc.setFont(FONT);
for(String line: readout)
{
gc.fillText(line, textX, lineY);
lineY += mTextHeight;
}
}
/**
* Height of a card with the lines
* @param lineCount of the header, candidate and more lines
* @param entries number of candidates
* @param more true when the card has a more line
* @param readoutLines number of readout lines
*/
private double cardHeight(int lineCount, int entries, boolean more, int readoutLines)
{
int gaps = 1 + entries + (more ? 1 : 0);
return ((lineCount + readoutLines) * mTextHeight) + (gaps * GAP) + PADDING + READOUT_PADDING;
}
/**
* Card text for as many candidates as fit the height: each candidate's title, details and source, shortened to
* the width
*/
private Card createCard(List<RankedCandidate> candidates, double maxTextWidth, double maxHeight,
int readoutLines)
{
int limit = Math.min(candidates.size(), MAX_CANDIDATES);
List<List<String>> entries = new ArrayList<>();
double textWidth = measure(HEADER, TITLE_FONT);
int lineCount = 1;
for(int index = 0; index < limit; index++)
{
RankedCandidate candidate = candidates.get(index);
List<String> lines = new ArrayList<>();
String title = candidate.candidate().title() != null ? candidate.candidate().title() : "(untitled)";
lines.add(fit(title, TITLE_FONT, maxTextWidth));
lines.add(fit(describe(candidate), FONT, maxTextWidth));
String source = source(candidate.candidate());
if(source != null)
{
lines.add(fit(source, FONT, maxTextWidth));
}
//Keep room for the more line when candidates are left out
boolean more = index + 1 < candidates.size();
if(!entries.isEmpty() && cardHeight(lineCount + lines.size() + (more ? 1 : 0), entries.size() + 1, more,
readoutLines) > maxHeight)
{
break;
}
textWidth = Math.max(textWidth, measure(lines.get(0), TITLE_FONT));
for(int line = 1; line < lines.size(); line++)
{
textWidth = Math.max(textWidth, measure(lines.get(line), FONT));
}
lineCount += lines.size();
entries.add(lines);
}
String more = candidates.size() > entries.size() ? "+ " + (candidates.size() - entries.size()) + " more" : null;
if(more != null)
{
textWidth = Math.max(textWidth, measure(more, FONT));
lineCount++;
}
return new Card(entries, more, textWidth, lineCount, maxTextWidth, maxHeight, readoutLines);
}
/**
* Frequency, mode or use, tone, encryption and distance of the candidate
*/
private String describe(RankedCandidate ranked)
{
FrequencyCandidate candidate = ranked.candidate();
return join(FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz", candidate.use(), candidate.mode(),
candidate.tone(), candidate.encrypted() ? "Encrypted" : null,
ranked.hasDistance() ? DistanceFormat.format(ranked.distanceKilometers()) : null);
}
/**
* Where the candidate is listed, with its alpha tag when the title isn't the alpha tag
*/
private static String source(FrequencyCandidate candidate)
{
String alphaTag = candidate.alphaTag() != null && !candidate.alphaTag().equals(candidate.title()) ?
candidate.alphaTag() : null;
return join(alphaTag, candidate.source());
}
/**
* Text shortened with an ellipsis to fit the width
*/
private String fit(String text, Font font, double maxWidth)
{
if(measure(text, font) <= maxWidth)
{
return text;
}
int length = text.length();
while(length > 1 && measure(text.substring(0, length) + "…", font) > maxWidth)
{
length--;
}
return text.substring(0, length) + "…";
}
private double measure(String text, Font font)
{
mMeasure.setFont(font);
mMeasure.setText(text);
double width = mMeasure.getLayoutBounds().getWidth();
mMeasure.setFont(FONT);
return width;
}
private static String join(String... values)
{
StringBuilder sb = new StringBuilder();
for(String value: values)
{
if(value != null && !value.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(" · ");
}
sb.append(value);
}
}
return sb.isEmpty() ? null : sb.toString();
}
private static Color opaque(Color color)
{
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
}

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

@@ -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.gui.table;
import javafx.scene.control.Button;
import javafx.scene.control.Tooltip;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.javafx.IconNode;
/**
* Button that opens the column configuration editor for a table.
*/
public class ColumnConfigurationButton extends Button
{
private final TableColumnMonitor<?> mTableColumnMonitor;
private ColumnConfigurationEditor mEditor;
/**
* Constructs an instance
* @param tableColumnMonitor for the table to configure
* @param title for the editor window
*/
public ColumnConfigurationButton(TableColumnMonitor<?> tableColumnMonitor, String title)
{
super("Columns");
mTableColumnMonitor = tableColumnMonitor;
IconNode iconNode = new IconNode(FontAwesome.COLUMNS);
//Follow the themed text color so that the icon is readable in light and dark themes
iconNode.fillProperty().bind(textFillProperty());
setGraphic(iconNode);
setTooltip(new Tooltip("Choose the columns to show and the column order"));
setOnAction(event -> {
if(mEditor == null)
{
mEditor = new ColumnConfigurationEditor(mTableColumnMonitor, title);
}
mEditor.show(this);
});
}
}

View File

@@ -0,0 +1,199 @@
/*
* *****************************************************************************
* 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.table;
import io.github.dsheirer.gui.theme.ThemeManager;
import javafx.collections.ListChangeListener;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.TableColumn;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxListCell;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.StringConverter;
/**
* Editor window for choosing the columns to show in a table and the order of the columns. Changes apply to the
* table immediately and are stored by the table column monitor.
*/
public class ColumnConfigurationEditor
{
private final TableColumnMonitor<?> mTableColumnMonitor;
private final String mTitle;
private Stage mStage;
private ListView<TableColumn<?,?>> mListView;
private Button mMoveUpButton;
private Button mMoveDownButton;
private Button mResetButton;
/**
* Constructs an instance
* @param tableColumnMonitor for the table
* @param title for the window
*/
public ColumnConfigurationEditor(TableColumnMonitor<?> tableColumnMonitor, String title)
{
mTableColumnMonitor = tableColumnMonitor;
mTitle = title;
}
/**
* Shows the editor next to the node, or brings it to the front when it is already showing.
* @param anchor node for positioning the editor window
*/
public void show(Node anchor)
{
if(mStage == null)
{
mStage = createStage();
}
if(!mStage.isShowing())
{
mListView.getSelectionModel().clearSelection();
if(anchor != null && anchor.getScene() != null)
{
Bounds bounds = anchor.localToScreen(anchor.getBoundsInLocal());
if(bounds != null)
{
mStage.setX(bounds.getMinX());
mStage.setY(bounds.getMaxY());
}
}
mStage.show();
}
mStage.toFront();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private Stage createStage()
{
Stage stage = new Stage();
stage.setTitle(mTitle);
mListView = new ListView<>();
//The list shares the table's column list, so that column header drags in the table show here too
mListView.setItems((javafx.collections.ObservableList)mTableColumnMonitor.getTableView().getColumns());
mListView.setCellFactory(CheckBoxListCell.forListView(TableColumn::visibleProperty,
new StringConverter<>()
{
@Override
public String toString(TableColumn<?,?> column)
{
return column != null ? TableColumnMonitor.getName(column) : null;
}
@Override
public TableColumn<?,?> fromString(String string)
{
return null;
}
}));
mListView.setPrefHeight(300);
mListView.setPrefWidth(220);
mListView.getSelectionModel().selectedIndexProperty().addListener((observable, oldValue, newValue) -> updateButtons());
mListView.getItems().addListener((ListChangeListener<TableColumn<?,?>>)c -> updateButtons());
mMoveUpButton = new Button("Move Up");
mMoveUpButton.setTooltip(new Tooltip("Move the selected column toward the left side of the table"));
mMoveUpButton.setOnAction(event -> move(-1));
mMoveDownButton = new Button("Move Down");
mMoveDownButton.setTooltip(new Tooltip("Move the selected column toward the right side of the table"));
mMoveDownButton.setOnAction(event -> move(1));
mResetButton = new Button("Reset");
mResetButton.setTooltip(new Tooltip("Restore the default columns and column order"));
mResetButton.setOnAction(event -> {
mTableColumnMonitor.reset();
mListView.getSelectionModel().clearSelection();
updateButtons();
});
for(Button button: new Button[]{mMoveUpButton, mMoveDownButton, mResetButton})
{
button.setMaxWidth(Double.MAX_VALUE);
}
Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
Button closeButton = new Button("Close");
closeButton.setMaxWidth(Double.MAX_VALUE);
closeButton.setOnAction(event -> stage.hide());
VBox buttonBox = new VBox(5, mMoveUpButton, mMoveDownButton, mResetButton, spacer, closeButton);
HBox listBox = new HBox(10, mListView, buttonBox);
HBox.setHgrow(mListView, Priority.ALWAYS);
VBox.setVgrow(listBox, Priority.ALWAYS);
Label instructions = new Label("Check the columns to show. You can also drag column headers in the " +
"table to change the column order.");
instructions.setWrapText(true);
instructions.setMaxWidth(320);
VBox content = new VBox(10, instructions, listBox);
content.setPadding(new Insets(10));
content.setAlignment(Pos.TOP_LEFT);
Scene scene = new Scene(content);
ThemeManager.getInstance().register(scene);
stage.setScene(scene);
stage.setOnShown(event -> updateButtons());
//Reset tracks whether the table has the default columns, so update it when visibility changes too
for(TableColumn<?,?> column: mTableColumnMonitor.getDefaultOrder())
{
column.visibleProperty().addListener((observable, oldValue, newValue) -> updateButtons());
}
return stage;
}
@SuppressWarnings({"unchecked", "rawtypes"})
private void move(int direction)
{
TableColumn column = mListView.getSelectionModel().getSelectedItem();
if(column != null && ((TableColumnMonitor)mTableColumnMonitor).move(column, direction))
{
mListView.getSelectionModel().select(column);
mListView.scrollTo(column);
}
}
private void updateButtons()
{
int index = mListView.getSelectionModel().getSelectedIndex();
mMoveUpButton.setDisable(index <= 0);
mMoveDownButton.setDisable(index < 0 || index >= mListView.getItems().size() - 1);
mResetButton.setDisable(mTableColumnMonitor.isDefault());
}
}

View File

@@ -0,0 +1,424 @@
/*
* *****************************************************************************
* 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.table;
import io.github.dsheirer.preference.swing.SwingPreference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.prefs.Preferences;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
/**
* Stores and restores the order, visibility and width of the columns of a JavaFX table across application sessions,
* and restores the default column order and visibility on request.
*
* Each column must have a unique, stable ID (TableColumn.setId()), which is the key for its stored settings. The
* order and visibility of the columns when this monitor is constructed are the defaults. A column that is added to
* the table in a later version of the application is shown at its default position.
*
* Use this class only on the JavaFX application thread.
*/
public class TableColumnMonitor<S>
{
/**
* Column property that holds the name shown for the column in the column configuration editor, when the
* column header text doesn't identify the column by itself (e.g. two columns titled 'Alias').
*/
public static final String CONFIGURATION_NAME = "column.configuration.name";
/**
* Column property that holds the column number of the column in the Swing version of the table, for the stored
* Swing column widths.
*/
public static final String LEGACY_INDEX = "column.legacy.index";
private static final String ORDER = ".column.order";
private static final String HIDDEN = ".column.hidden";
private static final String WIDTH = ".column.width.";
private static final String SEPARATOR = ",";
private final TableView<S> mTableView;
private final String mKey;
private final Preferences mPreferences;
private final List<TableColumn<S,?>> mDefaultOrder;
private final Map<TableColumn<S,?>,Boolean> mDefaultVisibility = new HashMap<>();
private boolean mRestoring;
/**
* Constructs an instance and applies the stored column settings to the table.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
*/
public TableColumnMonitor(TableView<S> tableView, String key)
{
this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), null, null);
}
/**
* Constructs an instance and applies the stored column settings to the table. Columns that don't have a stored
* width yet use the width stored by the Swing version of the table, so that users keep their column widths.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
* @param swingPreference that holds the column widths of the Swing table
* @param swingKey of the Swing table. The Swing column number is the default index of the column.
*/
public TableColumnMonitor(TableView<S> tableView, String key, SwingPreference swingPreference, String swingKey)
{
this(tableView, key, Preferences.userNodeForPackage(TableColumnMonitor.class), swingPreference, swingKey);
}
/**
* Constructs an instance and applies the stored column settings to the table.
* @param tableView to monitor
* @param key that uniquely identifies the table, used as the prefix for its stored settings
* @param preferences node for storing the settings
* @param swingPreference that holds legacy Swing column widths, or null
* @param swingKey for the legacy Swing column widths, or null
*/
TableColumnMonitor(TableView<S> tableView, String key, Preferences preferences, SwingPreference swingPreference,
String swingKey)
{
mTableView = tableView;
mKey = key;
mPreferences = preferences;
mDefaultOrder = List.copyOf(tableView.getColumns());
Set<String> ids = new HashSet<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.getId() == null || column.getId().isEmpty() || column.getId().contains(SEPARATOR) ||
!ids.add(column.getId()))
{
throw new IllegalArgumentException("Each table column needs a unique ID without commas - column [" +
column.getText() + "] has ID [" + column.getId() + "]");
}
mDefaultVisibility.put(column, column.isVisible());
}
restore(swingPreference, swingKey);
mTableView.getColumns().addListener((ListChangeListener<TableColumn<S,?>>)c -> {
if(!mRestoring)
{
storeOrder();
}
});
for(TableColumn<S,?> column: mDefaultOrder)
{
column.visibleProperty().addListener((observable, wasVisible, visible) -> {
if(!mRestoring)
{
//Don't let the user hide every column, since that leaves no column header for getting them back
if(!visible && getVisibleColumnCount() == 0)
{
column.setVisible(true);
return;
}
storeVisibility();
}
});
column.widthProperty().addListener((observable, oldWidth, width) -> {
if(!mRestoring && column.isVisible() && width.doubleValue() > 0)
{
mPreferences.putDouble(getWidthKey(column), width.doubleValue());
}
});
}
}
/**
* Table that is monitored.
*/
public TableView<S> getTableView()
{
return mTableView;
}
/**
* Columns in their default order.
*/
public List<TableColumn<S,?>> getDefaultOrder()
{
return mDefaultOrder;
}
/**
* Name of the column for display in the column configuration editor.
* @param column to name
* @return configuration name for the column, or the column header text.
*/
public static String getName(TableColumn<?,?> column)
{
Object name = column.getProperties().get(CONFIGURATION_NAME);
return name != null ? name.toString() : column.getText();
}
/**
* Sets the name of the column for display in the column configuration editor
* @param column to name
* @param name to show in the configuration editor
*/
public static void setName(TableColumn<?,?> column, String name)
{
column.getProperties().put(CONFIGURATION_NAME, name);
}
/**
* Sets the column number of the column in the Swing version of the table, for migrating the Swing column width.
* When any column of a table has a legacy index, only those columns use Swing column widths. Otherwise, the
* default column index is the Swing column number.
* @param column to set
* @param index of the column in the Swing table
*/
public static void setLegacyIndex(TableColumn<?,?> column, int index)
{
column.getProperties().put(LEGACY_INDEX, index);
}
/**
* Restores the default column order and visibility and removes the stored order and visibility settings. The
* column widths aren't changed.
*/
public void reset()
{
mRestoring = true;
try
{
if(!mTableView.getColumns().equals(mDefaultOrder))
{
mTableView.getColumns().setAll(mDefaultOrder);
}
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(mDefaultVisibility.get(column));
}
}
finally
{
mRestoring = false;
}
mPreferences.remove(mKey + ORDER);
mPreferences.remove(mKey + HIDDEN);
}
/**
* Indicates if the column order and visibility are the defaults.
*/
public boolean isDefault()
{
if(!mTableView.getColumns().equals(mDefaultOrder))
{
return false;
}
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.isVisible() != mDefaultVisibility.get(column))
{
return false;
}
}
return true;
}
/**
* Moves the column one position toward the start (-1) or the end (+1) of the table.
* @param column to move
* @param direction -1 or +1
* @return true if the column moved
*/
public boolean move(TableColumn<S,?> column, int direction)
{
ObservableList<TableColumn<S,?>> columns = mTableView.getColumns();
int index = columns.indexOf(column);
int target = index + Integer.signum(direction);
if(index < 0 || direction == 0 || target < 0 || target >= columns.size())
{
return false;
}
//Rearrange a copy and apply it in one change, since a table view can't hold the same column twice
List<TableColumn<S,?>> reordered = new ArrayList<>(columns);
Collections.swap(reordered, index, target);
columns.setAll(reordered);
return true;
}
/**
* Applies the stored column settings to the table.
*/
private void restore(SwingPreference swingPreference, String swingKey)
{
mRestoring = true;
try
{
Map<String,TableColumn<S,?>> columnMap = new LinkedHashMap<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
columnMap.put(column.getId(), column);
}
String order = mPreferences.get(mKey + ORDER, null);
if(order != null)
{
List<TableColumn<S,?>> ordered = new ArrayList<>();
for(String id: order.split(SEPARATOR))
{
TableColumn<S,?> column = columnMap.get(id);
if(column != null && !ordered.contains(column))
{
ordered.add(column);
}
}
//Columns that aren't in the stored order are new, so show them at their default position
for(int x = 0; x < mDefaultOrder.size(); x++)
{
TableColumn<S,?> column = mDefaultOrder.get(x);
if(!ordered.contains(column))
{
ordered.add(Math.min(x, ordered.size()), column);
}
}
mTableView.getColumns().setAll(ordered);
}
String hidden = mPreferences.get(mKey + HIDDEN, null);
if(hidden != null)
{
Set<String> hiddenIds = Set.of(hidden.isEmpty() ? new String[0] : hidden.split(SEPARATOR));
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(!hiddenIds.contains(column.getId()));
}
if(getVisibleColumnCount() == 0)
{
for(TableColumn<S,?> column: mDefaultOrder)
{
column.setVisible(mDefaultVisibility.get(column));
}
}
}
boolean hasLegacyIndexes = mDefaultOrder.stream()
.anyMatch(column -> column.getProperties().containsKey(LEGACY_INDEX));
for(int x = 0; x < mDefaultOrder.size(); x++)
{
TableColumn<S,?> column = mDefaultOrder.get(x);
double width = mPreferences.getDouble(getWidthKey(column), Double.NaN);
Integer legacyIndex = column.getProperties().get(LEGACY_INDEX) instanceof Integer index ? index :
(hasLegacyIndexes ? null : x);
if(Double.isNaN(width) && swingPreference != null && swingKey != null && legacyIndex != null)
{
int swingWidth = swingPreference.getInt(swingKey + ".column." + legacyIndex, Integer.MAX_VALUE);
if(swingWidth != Integer.MAX_VALUE && swingWidth > 0)
{
width = swingWidth;
}
}
if(!Double.isNaN(width) && width > 0)
{
column.setPrefWidth(width);
}
}
}
finally
{
mRestoring = false;
}
}
private int getVisibleColumnCount()
{
int count = 0;
for(TableColumn<S,?> column: mDefaultOrder)
{
if(column.isVisible())
{
count++;
}
}
return count;
}
private void storeOrder()
{
List<String> ids = new ArrayList<>();
for(TableColumn<S,?> column: mTableView.getColumns())
{
ids.add(column.getId());
}
mPreferences.put(mKey + ORDER, String.join(SEPARATOR, ids));
}
private void storeVisibility()
{
List<String> ids = new ArrayList<>();
for(TableColumn<S,?> column: mDefaultOrder)
{
if(!column.isVisible())
{
ids.add(column.getId());
}
}
mPreferences.put(mKey + HIDDEN, String.join(SEPARATOR, ids));
}
private String getWidthKey(TableColumn<S,?> column)
{
return mKey + WIDTH + column.getId();
}
}

View File

@@ -32,6 +32,7 @@ import java.lang.ref.WeakReference;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.scene.Scene;
import javax.swing.LookAndFeel;
@@ -69,6 +70,7 @@ public class ThemeManager
private static final ThemeManager INSTANCE = new ThemeManager();
private final Set<WeakReference<Scene>> mScenes = new LinkedHashSet<>();
private final AtomicBoolean mWindowListenerAttached = new AtomicBoolean();
private final Set<WeakReference<java.awt.Component>> mSwingRoots = new LinkedHashSet<>();
private final String mDarkStylesheetUrl;
private volatile UserPreferences mUserPreferences;
@@ -135,7 +137,18 @@ public class ThemeManager
*/
private void attachJavaFxWindowListener()
{
if(mWindowListenerAttached.get())
{
return;
}
Runnable attach = () -> {
//Attach once, even if several attempts were scheduled before the first one ran
if(!mWindowListenerAttached.compareAndSet(false, true))
{
return;
}
try
{
javafx.stage.Window.getWindows().addListener(
@@ -176,8 +189,9 @@ public class ThemeManager
}
catch(IllegalStateException ignored)
{
//FX toolkit not started yet - first JFXPanel construction will start it; we
//re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk.
//FX toolkit not started yet, which is the case when initialize() runs at application
//startup, before the first JFXPanel. register() attaches the listener once the
//first JavaFX scene is registered, which happens after the toolkit starts.
}
}
}
@@ -248,6 +262,9 @@ public class ThemeManager
mScenes.add(new WeakReference<>(scene));
}
//The toolkit is running once a scene exists, so attach the window listener if initialize()
//couldn't, so that ad-hoc dialogs and alerts are themed
attachJavaFxWindowListener();
applyToScene(scene, isDarkMode());
}

View File

@@ -19,7 +19,9 @@
package io.github.dsheirer.identifier.alias;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
@@ -116,6 +118,16 @@ public class TalkerAliasManager
}
}
/**
* Current FROM radio in the identifier collection
* @param identifierCollection to inspect
* @return FROM radio identifier, or null
*/
public static Identifier getFromRadio(IdentifierCollection identifierCollection)
{
return identifierCollection.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM);
}
/**
* Creates a summary listing of talker aliases
* @return summary.

View File

@@ -0,0 +1,323 @@
/*
* *****************************************************************************
* 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.identifier.alias;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Keeps the talker alias in a decoder's identifier collection matched to the radio that is talking, and assigns each
* received talker alias to the right radio in the talker alias manager. Use one instance for each decoder state (or
* timeslot) on the decoder state's thread.
*
* Talker aliases can arrive:
* <ul>
* <li>During the call, after the talking radio is identified (most protocols).</li>
* <li>During the call, before the talking radio is identified. The alias is shown for the call and stored for
* the radio once the radio is identified.</li>
* <li>Between calls. Depending on the protocol, the alias belongs to the talker of the call that just ended (e.g.
* P25 Phase 1 L3Harris aliases in the TDULC messages) or to the talker of the next call (e.g. P25 Phase 2
* L3Harris aliases that arrive just before the Push-To-Talk). See {@link BetweenCalls}.</li>
* <li>With their own radio identifier (e.g. Motorola), which is stored for that radio.</li>
* </ul>
*
* The decoder state calls {@link #callActivity(long)} after it updates the identifier collection for call activity,
* {@link #callEnded()} when a call ends, before it removes the FROM identifiers, and {@link #received} for each
* talker alias.
*/
public class TalkerAliasTracker
{
/**
* Talker that an alias received between calls belongs to
*/
public enum BetweenCalls
{
/**
* The talker of the call that just ended
*/
PREVIOUS_TALKER,
/**
* The talker of the next call, when it starts within {@link #NEXT_CALL_WINDOW_MS}
*/
NEXT_TALKER
}
/**
* Maximum time from a talker alias received between calls to the start of the next call, for the alias to belong
* to the next call's talker.
*/
public static final long NEXT_CALL_WINDOW_MS = 2000;
/**
* Call that a received talker alias belongs to
*/
public enum Assignment
{
/**
* The call in progress. The alias is shown in the identifier collection.
*/
CURRENT_CALL,
/**
* The call that just ended. The alias isn't shown, since no call is in progress.
*/
ENDED_CALL,
/**
* The next call. The alias is held and shown when the next call starts.
*/
NEXT_CALL,
/**
* Neither, e.g. an alias for a radio that isn't talking. The alias is stored for its radio, if known.
*/
UNASSIGNED
}
private final MutableIdentifierCollection mIdentifierCollection;
private final Supplier<TalkerAliasManager> mTalkerAliasManager;
private final BetweenCalls mBetweenCalls;
private boolean mCallActive;
private TalkerAliasIdentifier mNextCallAlias;
private long mNextCallAliasTimestamp;
private Identifier mTalker;
private RadioIdentifier mEndedCallRadio;
/**
* Constructs an instance
* @param identifierCollection of the decoder state
* @param talkerAliasManager for storing the talker alias of each radio. The supplier can return null when the
* decoder doesn't have a talker alias manager.
* @param betweenCalls talker that an alias received between calls belongs to, for the protocol
*/
public TalkerAliasTracker(MutableIdentifierCollection identifierCollection,
Supplier<TalkerAliasManager> talkerAliasManager, BetweenCalls betweenCalls)
{
mIdentifierCollection = identifierCollection;
mTalkerAliasManager = talkerAliasManager;
mBetweenCalls = betweenCalls;
}
/**
* Signals call activity, after the decoder updates the identifier collection from a call message. Keeps the
* talker alias matched to the talking radio: removes the alias when a different radio talks, stores an alias that
* arrived before the talking radio was identified, and adds the stored alias for the talking radio.
* @param timestamp of the call message
*/
public void callActivity(long timestamp)
{
if(!mCallActive && mNextCallAlias != null && Math.abs(timestamp - mNextCallAliasTimestamp) <= NEXT_CALL_WINDOW_MS)
{
//The alias received just before this call belongs to this call's talker. It's stored for the talker once
//the talker is identified.
mIdentifierCollection.update(mNextCallAlias);
}
mNextCallAlias = null;
mCallActive = true;
mEndedCallRadio = null;
update();
}
/**
* Keeps the talker alias matched to the talking radio after an identifier collection update, without signaling
* call activity.
*/
public void update()
{
Identifier current = getFromRadio();
if(current != null)
{
Identifier alias = getTalkerAlias();
TalkerAliasManager manager = mTalkerAliasManager.get();
if(mTalker != null && !isSameRadio(mTalker, current))
{
//A different radio is talking
mIdentifierCollection.remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
else if(mTalker == null && alias instanceof TalkerAliasIdentifier talkerAlias &&
current instanceof RadioIdentifier radio && manager != null)
{
//The alias arrived during this call before the radio was identified
manager.update(radio, talkerAlias);
}
if(manager != null)
{
manager.enrichMutable(mIdentifierCollection);
}
}
mTalker = current;
}
/**
* Signals the end of the call. Call before removing the FROM identifiers from the identifier collection.
* Remembers the talker for an alias that arrives after the call, and removes the talker alias. Can be called
* more than once for the same call.
*/
public void callEnded()
{
Identifier current = getFromRadio();
if(current instanceof RadioIdentifier radio)
{
mEndedCallRadio = radio;
}
else if(mTalker instanceof RadioIdentifier radio)
{
mEndedCallRadio = radio;
}
mCallActive = false;
mTalker = null;
mIdentifierCollection.remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
/**
* Resets the tracker, e.g. when the decoder state resets.
*/
public void reset()
{
mCallActive = false;
mTalker = null;
mEndedCallRadio = null;
mNextCallAlias = null;
}
/**
* Processes a received talker alias: stores it for its radio and shows it when the radio is talking.
*
* @param talkerAlias that was received
* @param aliasRadio that the alias identifies itself (e.g. Motorola), or null when the alias belongs to the
* talker of the current call, or of the previous or next call, depending on the protocol.
* @param timestamp of the alias message
* @return the call that the alias belongs to.
*/
public Assignment received(TalkerAliasIdentifier talkerAlias, RadioIdentifier aliasRadio, long timestamp)
{
if(talkerAlias == null)
{
return Assignment.UNASSIGNED;
}
Identifier current = getFromRadio();
RadioIdentifier radio = aliasRadio;
if(radio == null)
{
if(current instanceof RadioIdentifier currentRadio)
{
radio = currentRadio;
}
else if(!mCallActive)
{
if(mBetweenCalls == BetweenCalls.NEXT_TALKER)
{
//Hold the alias for the next call's talker
mNextCallAlias = talkerAlias;
mNextCallAliasTimestamp = timestamp;
return Assignment.NEXT_CALL;
}
radio = mEndedCallRadio;
}
}
TalkerAliasManager manager = mTalkerAliasManager.get();
if(radio != null && manager != null)
{
manager.update(radio, talkerAlias);
}
if(current != null && radio != null && isSameRadio(current, radio))
{
mIdentifierCollection.update(talkerAlias);
return Assignment.CURRENT_CALL;
}
if(radio == null && mCallActive)
{
//The talking radio isn't identified yet. The alias is stored for the radio once it's identified.
mIdentifierCollection.update(talkerAlias);
return Assignment.CURRENT_CALL;
}
if(radio != null && !mCallActive && mEndedCallRadio != null && isSameRadio(radio, mEndedCallRadio))
{
return Assignment.ENDED_CALL;
}
return Assignment.UNASSIGNED;
}
/**
* Talker of the call that just ended, or null.
*/
public RadioIdentifier getEndedCallRadio()
{
return mEndedCallRadio;
}
/**
* Talking radio for the current call, or null.
*/
public RadioIdentifier getCurrentRadio()
{
return getFromRadio() instanceof RadioIdentifier radio ? radio : null;
}
/**
* Indicates if a call is in progress
*/
public boolean isCallActive()
{
return mCallActive;
}
/**
* Indicates if the identifiers are the same radio, comparing radio values so that a radio and its fully qualified
* variant match.
*/
public static boolean isSameRadio(Identifier a, Identifier b)
{
if(a instanceof RadioIdentifier ra && b instanceof RadioIdentifier rb)
{
return Objects.equals(ra.getValue(), rb.getValue());
}
return Objects.equals(a, b);
}
private Identifier getFromRadio()
{
return TalkerAliasManager.getFromRadio(mIdentifierCollection);
}
private Identifier getTalkerAlias()
{
return mIdentifierCollection.getIdentifier(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
}
}

View File

@@ -17,7 +17,9 @@
******************************************************************************/
package io.github.dsheirer.map;
import io.github.dsheirer.settings.SettingsManager;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import org.jdesktop.swingx.JXMapViewer;
import org.jdesktop.swingx.mapviewer.GeoPosition;
@@ -33,14 +35,23 @@ import java.awt.geom.Point2D;
public class MapMouseListener extends MouseInputAdapter implements MouseWheelListener
{
private JXMapViewer mJXMapViewer;
private SettingsManager mSettingsManager;
private Consumer<GeoPosition> mMyLocationListener;
private Function<Point,List<JMenuItem>> mMenuItemProvider;
private Point mPreviousPoint;
private Point mCurrentPoint;
public MapMouseListener( JXMapViewer viewer, SettingsManager settingsManager )
/**
* Constructs an instance
* @param viewer map
* @param myLocationListener receives the location chosen with the 'Set My Location Here' menu item
* @param menuItemProvider of additional context menu items for the point that was right-clicked, listed first
*/
public MapMouseListener( JXMapViewer viewer, Consumer<GeoPosition> myLocationListener,
Function<Point,List<JMenuItem>> menuItemProvider )
{
mJXMapViewer = viewer;
mSettingsManager = settingsManager;
mMyLocationListener = myLocationListener;
mMenuItemProvider = menuItemProvider;
}
@Override
@@ -136,18 +147,27 @@ public class MapMouseListener extends MouseInputAdapter implements MouseWheelLis
else if( right )
{
JPopupMenu popup = new JPopupMenu();
if( mMenuItemProvider != null )
{
List<JMenuItem> items = mMenuItemProvider.apply( event.getPoint() );
if( items != null && !items.isEmpty() )
{
items.forEach( popup::add );
popup.addSeparator();
}
}
JMenuItem mapViewItem = new JMenuItem( "Set Default Location & Zoom" );
JMenuItem mapViewItem = new JMenuItem( "Set My Location Here" );
mapViewItem.setToolTipText( "Sets your location, which is also used by the playlist editor's " +
"Radio Reference tab, to this point" );
mapViewItem.addActionListener( new ActionListener()
{
@Override
public void actionPerformed( ActionEvent arg0 )
{
GeoPosition position = mJXMapViewer
.convertPointToGeoPosition( mCurrentPoint );
mSettingsManager.setMapViewSetting( "Default", position,
mJXMapViewer.getZoom() );
mMyLocationListener.accept( mJXMapViewer.convertPointToGeoPosition( mCurrentPoint ) );
}
} );
popup.add( mapViewItem );

View File

@@ -18,17 +18,30 @@
*/
package io.github.dsheirer.map;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.settings.MapViewSetting;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.map.MapPreference;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
import io.github.dsheirer.service.radioreference.SiteRecord;
import java.awt.EventQueue;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import net.miginfocom.swing.MigLayout;
import org.jdesktop.swingx.JXMapViewer;
import org.jdesktop.swingx.OSMTileFactoryInfo;
@@ -37,11 +50,14 @@ import org.jdesktop.swingx.input.ZoomMouseWheelListenerCursor;
import org.jdesktop.swingx.mapviewer.DefaultTileFactory;
import org.jdesktop.swingx.mapviewer.GeoPosition;
import org.jdesktop.swingx.mapviewer.TileFactoryInfo;
import org.jdesktop.swingx.painter.CompoundPainter;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@@ -49,6 +65,9 @@ import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.JToggleButton;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingConstants;
import javax.swing.Timer;
import javax.swing.ToolTipManager;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
@@ -61,12 +80,28 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
private static final int ZOOM_MINIMUM = 1;
private static final int ZOOM_MAXIMUM = 16;
//Delay after the last pan or zoom before the map view is stored
private static final int SAVE_VIEW_DELAY_MS = 500;
private static final String FOLLOW = "Follow";
private static final String UNFOLLOW = "Unfollow";
private static final String SELECT_A_TRACK = "(select a track)";
private static final String NO_SYSTEM_NAME = "(no system name)";
private SettingsManager mSettingsManager;
private static final String LOCATION_NOT_SET = "Not set";
private Timer mSaveViewTimer;
private final RadioReferenceFrequencyIndex mFrequencyIndex;
private final RadioReferenceSitePainter mSitePainter;
private final RadioReferencePlaylistActions mPlaylistActions;
private JCheckBox mShowSitesCheckBox;
private JLabel mSiteCountLabel;
private int mDefaultTooltipDismissDelay = -1;
private UserPreferences mUserPreferences;
private final UserLocationPainter mUserLocationPainter = new UserLocationPainter();
private JLabel mUserLocationLabel;
private JButton mSetUserLocationButton;
private JButton mCenterOnUserLocationButton;
//Location most recently chosen on the map, which doesn't re-center the map when the preference update arrives
private GeoPosition mMapChosenPosition;
private MapService mMapService;
private JXMapViewer mMapViewer;
private PlottableEntityPainter mMapPainter;
@@ -93,15 +128,354 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
* @param mapService for accessing entities to plot
* @param aliasModel for alias lookup
* @param iconModel for icon lookup
* @param settingsManager for user specified options/settings.
* @param userPreferences for the map view and the user's location, which is shared with the radio reference editor
* @param frequencyIndex of the radio reference frequencies near the user, for the trunked system sites, or null
* @param playlistActions to add sites to the playlist or enable their channels, or null
*/
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, SettingsManager settingsManager)
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, UserPreferences userPreferences,
RadioReferenceFrequencyIndex frequencyIndex, RadioReferencePlaylistActions playlistActions)
{
mSettingsManager = settingsManager;
mUserPreferences = userPreferences;
mFrequencyIndex = frequencyIndex;
mPlaylistActions = playlistActions;
mSitePainter = new RadioReferenceSitePainter(frequencyIndex != null ? frequencyIndex::getSites :
Collections::emptyList);
mSitePainter.setShowSites(userPreferences.getMapPreference().isShowRadioReferenceSites());
mMapService = mapService;
mMapPainter = new PlottableEntityPainter(aliasModel, iconModel);
init();
//Show the stored location without moving the map, which starts at the stored default location
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
mUserLocationPainter.setPosition(location != null ? toGeoPosition(location) : null);
updateUserLocationLabel(location);
if(mFrequencyIndex != null)
{
//The index fills in the background
mFrequencyIndex.addUpdateListener(() -> EventQueue.invokeLater(this::sitesUpdated));
}
sitesUpdated();
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Updates the site count and repaints the sites. Call on the Swing thread.
*/
private void sitesUpdated()
{
int count = mFrequencyIndex != null ? mFrequencyIndex.getSites().size() : 0;
getSiteCountLabel().setText(count == 1 ? "1 site" : count + " sites");
getMapViewer().repaint();
}
private JCheckBox getShowSitesCheckBox()
{
if(mShowSitesCheckBox == null)
{
mShowSitesCheckBox = new JCheckBox("Radio Reference Sites", mSitePainter.isShowSites());
mShowSitesCheckBox.setEnabled(mFrequencyIndex != null);
mShowSitesCheckBox.setToolTipText("Show the trunked radio system sites within " +
(int)RadioReferenceFrequencyIndex.RADIUS_KILOMETERS + " km of your location. Requires a premium " +
"Radio Reference login and your location.");
mShowSitesCheckBox.addActionListener(e -> {
boolean show = mShowSitesCheckBox.isSelected();
mSitePainter.setShowSites(show);
mUserPreferences.getMapPreference().setShowRadioReferenceSites(show);
getMapViewer().repaint();
});
}
return mShowSitesCheckBox;
}
private JLabel getSiteCountLabel()
{
if(mSiteCountLabel == null)
{
mSiteCountLabel = new JLabel();
}
return mSiteCountLabel;
}
/**
* Context menu items for the radio reference sites under the point: add a site to the playlist and start it, or
* enable or disable the playlist channel that already decodes it
*/
private List<JMenuItem> getSiteMenuItems(Point point)
{
List<JMenuItem> items = new ArrayList<>();
if(mPlaylistActions == null)
{
return items;
}
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
for(SiteRecord site: sites.subList(0, Math.min(sites.size(), 3)))
{
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
(site.description() != null ? " " + site.description() : "");
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
if(unsupported != null)
{
JMenuItem item = new JMenuItem(label + " \u2013 " + unsupported);
item.setEnabled(false);
items.add(item);
continue;
}
if(RadioReferencePlaylistActions.isRepeaterSite(site))
{
addRepeaterSiteItems(items, site, label);
continue;
}
Channel existing = mPlaylistActions.findSiteChannel(site);
if(existing != null)
{
boolean processing = existing.isProcessing();
JMenuItem item = new JMenuItem((processing ? "Disable: " : "Enable: ") + existing.getShortTitle());
item.addActionListener(e -> {
if(processing)
{
mPlaylistActions.disable(existing, this::showSiteError);
}
else
{
mPlaylistActions.enable(existing, this::showSiteError);
}
});
items.add(item);
}
else
{
JMenuItem item = new JMenuItem("Add and Start: " + label);
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
items.add(item);
}
}
return items;
}
/**
* Menu items for a site whose repeaters each have a channel (LTR): add and start the repeaters that aren't in the
* playlist, and enable or disable the site's channels
*/
private void addRepeaterSiteItems(List<JMenuItem> items, SiteRecord site, String label)
{
List<Channel> existing = mPlaylistActions.findSiteChannels(site);
int missing = mPlaylistActions.getMissingRepeaterFrequencies(site).size();
boolean anyDecoding = existing.stream().anyMatch(Channel::isProcessing);
boolean anyStopped = existing.stream().anyMatch(channel -> !channel.isProcessing());
if(missing > 0)
{
JMenuItem item = new JMenuItem("Add and Start: " + label + " (" + missing + " repeater" +
(missing > 1 ? "s" : "") + ")");
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
items.add(item);
}
else if(anyStopped)
{
JMenuItem item = new JMenuItem("Enable: " + label + " (" + existing.size() + " channels)");
item.addActionListener(e -> mPlaylistActions.enableAll(existing, this::showSiteError));
items.add(item);
}
if(anyDecoding)
{
long decoding = existing.stream().filter(Channel::isProcessing).count();
JMenuItem item = new JMenuItem("Disable: " + label + " (" + decoding + " channel" +
(decoding > 1 ? "s" : "") + ")");
item.addActionListener(e -> mPlaylistActions.disableAll(existing, this::showSiteError));
items.add(item);
}
}
/**
* Shows an error from a site action. Can be called from any thread.
*/
private void showSiteError(String message)
{
EventQueue.invokeLater(() -> JOptionPane.showMessageDialog(this, message, "Radio Reference",
JOptionPane.ERROR_MESSAGE));
}
/**
* Card for the radio reference sites under the point, or null when there are none
*/
private String getSiteCard(Point point)
{
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
return sites.isEmpty() ? null : RadioReferenceSiteCard.html(sites,
mUserPreferences.getRadioReferencePreference().getLocation());
}
/**
* Updates the user's location on the map when it changes, here or in the radio reference editor
* @param preferenceType that was updated
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.RADIO_REFERENCE)
{
EventQueue.invokeLater(this::updateUserLocation);
}
}
/**
* Shows the user's location on the map. When the location changes, the map centers on it, unless it was chosen on
* the map. Clearing the location removes the pin and leaves the map as is. Call on the Swing thread.
*/
private void updateUserLocation()
{
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
GeoPosition position = location != null ? toGeoPosition(location) : null;
updateUserLocationLabel(location);
if(!Objects.equals(position, mUserLocationPainter.getPosition()))
{
mUserLocationPainter.setPosition(position);
//A location chosen on the map is already in view
if(position != null && !position.equals(mMapChosenPosition))
{
getMapViewer().setAddressLocation(position);
}
mMapChosenPosition = null;
getMapViewer().repaint();
}
}
private void updateUserLocationLabel(RadioReferencePreference.Location location)
{
getUserLocationLabel().setText(location != null ? location.coordinates() : LOCATION_NOT_SET);
getCenterOnUserLocationButton().setEnabled(location != null);
getUserLocationLabel().setToolTipText(location != null ? location.describe() :
"Set your location with the Set Current button or in the playlist editor's Radio Reference tab");
}
private JLabel getUserLocationLabel()
{
if(mUserLocationLabel == null)
{
mUserLocationLabel = new JLabel(LOCATION_NOT_SET);
}
return mUserLocationLabel;
}
/**
* Button that sets the user's location to the center of the map
*/
private JButton getSetUserLocationButton()
{
if(mSetUserLocationButton == null)
{
mSetUserLocationButton = new JButton("Set Current");
mSetUserLocationButton.setToolTipText("Sets your location, which is also used by the playlist editor's " +
"Radio Reference tab, to the center of the map");
mSetUserLocationButton.addActionListener(e -> setUserLocation(getMapViewer().getCenterPosition()));
}
return mSetUserLocationButton;
}
/**
* Button that centers the map on the user's location. Enabled when the location is set.
*/
private JButton getCenterOnUserLocationButton()
{
if(mCenterOnUserLocationButton == null)
{
mCenterOnUserLocationButton = new JButton("Center On");
mCenterOnUserLocationButton.setToolTipText("Centers the map on your location");
mCenterOnUserLocationButton.setEnabled(false);
mCenterOnUserLocationButton.addActionListener(e -> {
GeoPosition position = mUserLocationPainter.getPosition();
if(position != null)
{
getMapViewer().setCenterPosition(position);
}
});
}
return mCenterOnUserLocationButton;
}
/**
* Sets the user's location to a point chosen on the map, with the Set Current button or the map's 'Set My Location
* Here' menu item. The pin, the location label and the radio reference editor follow through the preference
* update. The map isn't re-centered, since the point is already in view.
* Call on the Swing thread.
* @param position chosen on the map
*/
private void setUserLocation(GeoPosition position)
{
double latitude = round(position.getLatitude());
double longitude = round(normalizeLongitude(position.getLongitude()));
if(RadioReferencePreference.Location.isValid(latitude, longitude))
{
mMapChosenPosition = new GeoPosition(latitude, longitude);
mUserPreferences.getRadioReferencePreference()
.setLocation(new RadioReferencePreference.Location(latitude, longitude, null));
}
}
/**
* Stores the map's center point and zoom level, so the map shows the same view when the application starts.
* Call on the Swing thread.
*/
private void saveView()
{
GeoPosition center = getMapViewer().getCenterPosition();
MapPreference preference = mUserPreferences.getMapPreference();
try
{
preference.setCenter(new MapPreference.Center(round(center.getLatitude()),
round(normalizeLongitude(center.getLongitude()))));
}
catch(IllegalArgumentException e)
{
//Not a storable position - keep the previous center
}
preference.setZoom(getMapViewer().getZoom());
}
/**
* Wraps a longitude into -180 to 180 degrees, since the map can be panned past +/-180 degrees
*/
private static double normalizeLongitude(double longitude)
{
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
}
/**
* Rounds degrees to six decimal places (about 0.1 meter)
*/
private static double round(double degrees)
{
return Math.round(degrees * 1_000_000.0) / 1_000_000.0;
}
private static GeoPosition toGeoPosition(RadioReferencePreference.Location location)
{
return new GeoPosition(location.latitude(), location.longitude());
}
private void init()
@@ -111,15 +485,23 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
JPanel cp = new JPanel();
cp.setLayout(new MigLayout("insets 5", "[grow,fill][grow,fill][grow,fill]",
"[grow,fill][][][][][][grow,fill][]"));
cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap");
"[][][][][grow,fill][][][][][][grow,fill][]"));
cp.add(getMapZoomSpinner());
JPanel zoomCenterPanel = new JPanel();
zoomCenterPanel.setLayout(new MigLayout("insets 0", "[]5[grow,fill]", ""));
zoomCenterPanel.add(new JLabel("Zoom"));
zoomCenterPanel.add(getCenterOnSelectedCheckBox());
cp.add(zoomCenterPanel, "span 2, wrap");
//The columns stretch the labels to the column width, so the label aligns its own text
cp.add(new JLabel("My Location:", SwingConstants.RIGHT), "align right");
cp.add(getUserLocationLabel(), "span 2, wrap");
cp.add(getSetUserLocationButton());
cp.add(getCenterOnUserLocationButton(), "wrap");
cp.add(new JLabel("Zoom:", SwingConstants.RIGHT), "align right");
//Spinner at its natural width, rather than filling the column
cp.add(getMapZoomSpinner(), "wmax pref, wrap");
cp.add(getShowSitesCheckBox(), "span 2");
cp.add(getSiteCountLabel(), "wrap");
cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap");
cp.add(getDeleteTrackButton());
cp.add(getDeleteAllTracksButton());
@@ -132,7 +514,9 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
cp.add(getTrackHistoryLengthComboBox());
cp.add(new JLabel("Track History Length"), "span 2, wrap");
cp.add(new JLabel("Selected System:"), "align right");
cp.add(getCenterOnSelectedCheckBox(), "span 3, wrap");
cp.add(new JLabel("Selected System:", SwingConstants.RIGHT), "align right");
cp.add(getSelectedTrackSystemLabel(), "span 2, wrap");
cp.add(new JScrollPane(getTrackHistoryTable()), "span 3,wrap");
@@ -526,12 +910,47 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
{
if(mMapViewer == null)
{
mMapViewer = new JXMapViewer();
//The tooltip is the card of the radio reference sites under the mouse
mMapViewer = new JXMapViewer()
{
@Override
public String getToolTipText(MouseEvent event)
{
return getSiteCard(event.getPoint());
}
@Override
public Point getToolTipLocation(MouseEvent event)
{
return new Point(event.getX() + 16, event.getY() + 16);
}
};
ToolTipManager.sharedInstance().registerComponent(mMapViewer);
//Keep the card up long enough to read the channels while the mouse is over the map
mMapViewer.addMouseListener(new MouseAdapter()
{
@Override
public void mouseEntered(MouseEvent event)
{
mDefaultTooltipDismissDelay = ToolTipManager.sharedInstance().getDismissDelay();
ToolTipManager.sharedInstance().setDismissDelay(60_000);
}
@Override
public void mouseExited(MouseEvent event)
{
if(mDefaultTooltipDismissDelay >= 0)
{
ToolTipManager.sharedInstance().setDismissDelay(mDefaultTooltipDismissDelay);
}
}
});
/**
* Set the entity painter as the overlay painter and register this panel to receive new messages (plots)
*/
mMapViewer.setOverlayPainter(mMapPainter);
//Radio reference sites under the tracks, and the user's location pin over the tracks
mMapViewer.setOverlayPainter(new CompoundPainter<>(mSitePainter, mMapPainter, mUserLocationPainter));
/**
* Map image source
@@ -546,20 +965,46 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
tileFactory.setThreadPoolSize(8);
/**
* Set initial location and zoom for the map upon display
* Set initial location and zoom for the map upon display: the last view, or else the user's location, or
* else Syracuse, NY.
*/
GeoPosition syracuse = new GeoPosition(43.048, -76.147);
int zoom = 7;
MapPreference mapPreference = mUserPreferences.getMapPreference();
MapPreference.Center center = mapPreference.getCenter();
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
GeoPosition initial;
MapViewSetting view = mSettingsManager.getMapViewSetting("Default", syracuse, zoom);
if(center != null)
{
initial = new GeoPosition(center.latitude(), center.longitude());
}
else if(location != null)
{
initial = toGeoPosition(location);
}
else
{
initial = new GeoPosition(43.048, -76.147);
}
mMapViewer.setAddressLocation(view.getGeoPosition());
mMapZoomSpinnerModel.setValue(view.getZoom());
//Set the zoom on the map too: the spinner doesn't update the map when the value equals its initial value
mMapViewer.setZoom(mapPreference.getZoom());
mMapViewer.setAddressLocation(initial);
mMapZoomSpinnerModel.setValue(mapPreference.getZoom());
//Remember the view after each pan or zoom, once the map stops moving
mSaveViewTimer = new Timer(SAVE_VIEW_DELAY_MS, e -> saveView());
mSaveViewTimer.setRepeats(false);
mMapViewer.addPropertyChangeListener(event -> {
if("centerPosition".equals(event.getPropertyName()) || "zoom".equals(event.getPropertyName()))
{
mSaveViewTimer.restart();
}
});
/**
* Add a mouse adapter for panning and scrolling
*/
MapMouseListener listener = new MapMouseListener(mMapViewer, mSettingsManager);
MapMouseListener listener = new MapMouseListener(mMapViewer, this::setUserLocation, this::getSiteMenuItems);
mMapViewer.addMouseListener(listener);
mMapViewer.addMouseMotionListener(listener);

View File

@@ -0,0 +1,187 @@
/*
* *****************************************************************************
* 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.map;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
import io.github.dsheirer.rrapi.type.Coverage;
import io.github.dsheirer.service.radioreference.DistanceFormat;
import io.github.dsheirer.service.radioreference.SiteRecord;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* HTML card with the details of radio reference trunked system sites, shown when the mouse is over a site's tower on
* the map: the system and protocol, the site, and the site's channels.
*/
public final class RadioReferenceSiteCard
{
private static final int MAX_SITES = 3;
private static final int CHANNEL_COLUMNS = 3;
private static final int MAX_CHANNELS = 24;
private static final String SEPARATOR = " &middot; ";
private RadioReferenceSiteCard()
{
}
/**
* Card for the sites
* @param sites under the mouse, nearest first
* @param location of the user, for the distances, or null
* @return HTML for a tooltip
*/
public static String html(List<SiteRecord> sites, RadioReferencePreference.Location location)
{
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
StringBuilder sb = new StringBuilder("<html><body style='padding:3px'>");
int shown = Math.min(sites.size(), MAX_SITES);
for(int index = 0; index < shown; index++)
{
SiteRecord site = sites.get(index);
if(index > 0)
{
sb.append("<hr>");
}
sb.append("<div><b>").append(escape(site.systemName() != null ? site.systemName() : "(unnamed system)"))
.append("</b></div>");
appendLine(sb, site.protocol(), site.voice());
String siteName = "Site " + site.siteNumber();
String distance = location != null ? DistanceFormat.format(Coverage.distanceKilometers(
location.latitude(), location.longitude(), site.latitude(), site.longitude())) : null;
appendLine(sb, siteName, site.description(), site.county(), distance);
appendLine(sb, site.nac() != null ? "NAC " + site.nac() : null,
site.rfss() > 0 ? "RFSS " + site.rfss() : null, site.zone() > 0 ? "Zone " + site.zone() : null);
appendChannels(sb, site.channels(), frequencyFormat);
}
if(sites.size() > shown)
{
sb.append("<hr><div>+ ").append(sites.size() - shown).append(" more site")
.append(sites.size() - shown > 1 ? "s" : "").append(" here</div>");
}
sb.append("</body></html>");
return sb.toString();
}
/**
* Channels in columns, control channels marked C and alternate control channels marked A
*/
private static void appendChannels(StringBuilder sb, List<SiteRecord.SiteChannel> channels,
DecimalFormat frequencyFormat)
{
if(channels == null || channels.isEmpty())
{
return;
}
int shown = Math.min(channels.size(), MAX_CHANNELS);
boolean control = false;
sb.append("<table cellspacing='0' cellpadding='1'>");
for(int index = 0; index < shown; index++)
{
if(index % CHANNEL_COLUMNS == 0)
{
sb.append("<tr>");
}
SiteRecord.SiteChannel channel = channels.get(index);
sb.append("<td style='padding-right:8px'>").append(frequencyFormat.format(channel.frequencyHz() / 1E6));
if("Control".equals(channel.use()))
{
sb.append(" <b>C</b>");
control = true;
}
else if("Alternate control".equals(channel.use()))
{
sb.append(" <b>A</b>");
control = true;
}
sb.append("</td>");
if(index % CHANNEL_COLUMNS == CHANNEL_COLUMNS - 1)
{
sb.append("</tr>");
}
}
if(shown % CHANNEL_COLUMNS != 0)
{
sb.append("</tr>");
}
sb.append("</table>");
List<String> notes = new ArrayList<>();
if(channels.size() > shown)
{
notes.add("+ " + (channels.size() - shown) + " more channels");
}
if(control)
{
notes.add("C control, A alternate control");
}
if(!notes.isEmpty())
{
appendLine(sb, notes.toArray(new String[0]));
}
}
/**
* Appends a line with the non-empty values separated by dots, or nothing when all are empty
*/
private static void appendLine(StringBuilder sb, String... values)
{
StringBuilder line = new StringBuilder();
for(String value: values)
{
if(value != null && !value.isBlank())
{
if(!line.isEmpty())
{
line.append(SEPARATOR);
}
line.append(escape(value));
}
}
if(!line.isEmpty())
{
sb.append("<div>").append(line).append("</div>");
}
}
private static String escape(String text)
{
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}

View File

@@ -0,0 +1,184 @@
/*
* *****************************************************************************
* 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.map;
import io.github.dsheirer.service.radioreference.SiteRecord;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Stroke;
import java.awt.geom.Arc2D;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import org.jdesktop.swingx.JXMapViewer;
import org.jdesktop.swingx.mapviewer.GeoPosition;
import org.jdesktop.swingx.painter.AbstractPainter;
/**
* Paints a radio tower at each radio reference trunked system site, with the base of the tower at the site location.
*/
public class RadioReferenceSitePainter extends AbstractPainter<JXMapViewer>
{
private static final Color TOWER = new Color(240, 128, 32);
private static final Color OUTLINE = new Color(40, 40, 40, 200);
private static final Stroke TOWER_STROKE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
private static final Stroke OUTLINE_STROKE = new BasicStroke(3.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
private static final double HEIGHT = 16;
private static final double HALF_BASE = 5;
//Hit area around the tower, in pixels from the site location
private static final Rectangle HIT_AREA = new Rectangle(-10, -24, 20, 28);
private final Supplier<List<SiteRecord>> mSites;
private volatile boolean mShowSites = true;
/**
* Constructs an instance
* @param sites to paint, read each time the map is painted
*/
public RadioReferenceSitePainter(Supplier<List<SiteRecord>> sites)
{
mSites = sites;
setAntialiasing(true);
setCacheable(false);
}
/**
* Shows or hides the sites
*/
public void setShowSites(boolean show)
{
mShowSites = show;
}
public boolean isShowSites()
{
return mShowSites;
}
/**
* Sites whose tower is under the point, nearest first
* @param map viewer
* @param point in the viewer
* @return sites, or an empty list when the sites are hidden or none is under the point
*/
public List<SiteRecord> getSitesAt(JXMapViewer map, Point point)
{
if(!mShowSites)
{
return Collections.emptyList();
}
Rectangle viewport = map.getViewportBounds();
List<SiteRecord> sites = new ArrayList<>();
List<Double> distances = new ArrayList<>();
for(SiteRecord site: mSites.get())
{
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
map.getZoom());
double dx = point.x - (pixel.getX() - viewport.getX());
double dy = point.y - (pixel.getY() - viewport.getY());
if(HIT_AREA.contains(dx, dy))
{
//Nearest to the middle of the tower first
double distance = Math.hypot(dx, dy + (HEIGHT / 2.0));
int index = 0;
while(index < distances.size() && distances.get(index) <= distance)
{
index++;
}
sites.add(index, site);
distances.add(index, distance);
}
}
return sites;
}
@Override
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
{
if(!mShowSites)
{
return;
}
Rectangle viewport = map.getViewportBounds();
for(SiteRecord site: mSites.get())
{
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
map.getZoom());
double x = pixel.getX() - viewport.getX();
double y = pixel.getY() - viewport.getY();
if(x >= -20 && x <= width + 20 && y >= -4 && y <= height + 30)
{
paintTower(g, x, y);
}
}
}
/**
* Paints a tower with its base at the point: an A-frame mast with a cross brace and radio waves at the top
*/
private static void paintTower(Graphics2D g, double x, double y)
{
double top = y - HEIGHT;
Path2D mast = new Path2D.Double();
mast.moveTo(x - HALF_BASE, y);
mast.lineTo(x, top);
mast.lineTo(x + HALF_BASE, y);
mast.moveTo(x - (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
mast.lineTo(x + (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
Arc2D leftInner = new Arc2D.Double(x - 5, top - 4, 10, 8, 135, 90, Arc2D.OPEN);
Arc2D rightInner = new Arc2D.Double(x - 5, top - 4, 10, 8, -45, 90, Arc2D.OPEN);
Arc2D leftOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, 135, 90, Arc2D.OPEN);
Arc2D rightOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, -45, 90, Arc2D.OPEN);
g.setStroke(OUTLINE_STROKE);
g.setColor(OUTLINE);
g.draw(mast);
g.draw(leftInner);
g.draw(rightInner);
g.draw(leftOuter);
g.draw(rightOuter);
g.setStroke(TOWER_STROKE);
g.setColor(TOWER);
g.draw(mast);
g.draw(leftInner);
g.draw(rightInner);
g.draw(leftOuter);
g.draw(rightOuter);
g.fill(new Ellipse2D.Double(x - 2, top - 2, 4, 4));
}
}

View File

@@ -0,0 +1,109 @@
/*
* *****************************************************************************
* 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.map;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import org.jdesktop.swingx.JXMapViewer;
import org.jdesktop.swingx.mapviewer.GeoPosition;
import org.jdesktop.swingx.painter.AbstractPainter;
/**
* Paints a push-pin at the user's location, with the tip of the pin at the location.
*/
public class UserLocationPainter extends AbstractPainter<JXMapViewer>
{
private static final Color NEEDLE = new Color(90, 90, 90);
private static final Color HEAD = new Color(220, 40, 40);
private static final Color HEAD_OUTLINE = new Color(110, 0, 0);
private static final Color HIGHLIGHT = new Color(255, 255, 255, 170);
private static final Color SHADOW = new Color(0, 0, 0, 70);
private static final double NEEDLE_LENGTH = 14.0;
private static final double HEAD_RADIUS = 7.0;
private volatile GeoPosition mPosition;
/**
* Constructs an instance
*/
public UserLocationPainter()
{
setAntialiasing(true);
setCacheable(false);
}
/**
* Sets the location to mark
* @param position of the user, or null to remove the pin
*/
public void setPosition(GeoPosition position)
{
mPosition = position;
}
/**
* Location that is marked
* @return position, or null
*/
public GeoPosition getPosition()
{
return mPosition;
}
@Override
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
{
GeoPosition position = mPosition;
if(position == null)
{
return;
}
Rectangle viewportBounds = map.getViewportBounds();
Point2D point = map.getTileFactory().geoToPixel(position, map.getZoom());
double x = point.getX() - viewportBounds.getX();
double y = point.getY() - viewportBounds.getY();
double headY = y - NEEDLE_LENGTH - HEAD_RADIUS;
//Shadow where the needle enters the map, so the tip reads as the location
g.setColor(SHADOW);
g.fill(new Ellipse2D.Double(x - 4, y - 2, 8, 4));
g.setColor(NEEDLE);
g.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(new Line2D.Double(x, y, x, headY));
Ellipse2D head = new Ellipse2D.Double(x - HEAD_RADIUS, headY - HEAD_RADIUS, HEAD_RADIUS * 2, HEAD_RADIUS * 2);
g.setColor(HEAD);
g.fill(head);
g.setColor(HEAD_OUTLINE);
g.setStroke(new BasicStroke(1.0f));
g.draw(head);
g.setColor(HIGHLIGHT);
g.fill(new Ellipse2D.Double(x - HEAD_RADIUS / 2 - 1, headY - HEAD_RADIUS / 2 - 1, 4, 4));
}
}

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

@@ -20,12 +20,12 @@ package io.github.dsheirer.module.decode.dmr;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.channel.state.ChangeChannelTimeoutEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.DecoderStateEvent.Event;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.channel.state.TimeslotDecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.Channel.ChannelType;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelConfigurationChangeNotification;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.Identifier;
@@ -33,9 +33,10 @@ import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.DmrTalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasIdentifier;
import io.github.dsheirer.identifier.alias.TalkerAliasManager;
import io.github.dsheirer.identifier.alias.TalkerAliasTracker;
import io.github.dsheirer.identifier.integer.IntegerIdentifier;
import io.github.dsheirer.identifier.radio.RadioIdentifier;
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
import io.github.dsheirer.log.LoggingSuppressor;
import io.github.dsheirer.message.EmptyTimeslotPlaceholderMessage;
@@ -126,6 +127,11 @@ public class DMRDecoderState extends TimeslotDecoderState
private DMRNetworkConfigurationMonitor mNetworkConfigurationMonitor;
private DMRTrafficChannelManager mTrafficChannelManager;
private DecodeEvent mCurrentCallEvent;
//Call event of the call that just ended, for a talker alias that completes after the call ends
private DecodeEvent mEndedCallEvent;
//Keeps the talker alias matched to the talking radio and assigns received talker aliases to the right radio
private final TalkerAliasTracker mTalkerAliasTracker = new TalkerAliasTracker(getIdentifierCollection(),
this::getTalkerAliasManager, TalkerAliasTracker.BetweenCalls.PREVIOUS_TALKER);
private boolean mIgnoreCRCChecksums;
private DMRDecoderState mSisterDecoderState;
@@ -1315,24 +1321,54 @@ public class DMRDecoderState extends TimeslotDecoderState
case FULL_STANDARD_TALKER_ALIAS_COMPLETE:
if(message instanceof TalkerAliasComplete tac && tac.hasTalkerAliasIdentifier())
{
getIdentifierCollection().update(tac.getTalkerAliasIdentifier());
Identifier fromRadio = getIdentifierCollection().getFromIdentifier();
if(hasTrafficChannelManager() && fromRadio instanceof RadioIdentifier radio)
{
mTrafficChannelManager.getTalkerAliasManager().update(radio, tac.getTalkerAliasIdentifier());
}
if(mCurrentCallEvent != null)
{
broadcast(mCurrentCallEvent);
}
processTalkerAlias(tac.getTalkerAliasIdentifier(), tac.getTimestamp());
}
break;
}
}
/**
* Talker alias manager shared with the traffic channel manager, or null when there's no traffic channel manager.
*/
private TalkerAliasManager getTalkerAliasManager()
{
return hasTrafficChannelManager() ? mTrafficChannelManager.getTalkerAliasManager() : null;
}
/**
* Processes a talker alias: stores it for the talking radio and adds it to the call that it belongs to.
* @param talkerAlias that was received
* @param timestamp of the message
*/
private void processTalkerAlias(TalkerAliasIdentifier talkerAlias, long timestamp)
{
switch(mTalkerAliasTracker.received(talkerAlias, null, timestamp))
{
case CURRENT_CALL ->
{
if(mCurrentCallEvent != null)
{
mCurrentCallEvent.setIdentifierCollection(getIdentifierCollection().copyOf());
broadcast(mCurrentCallEvent);
}
}
case ENDED_CALL ->
{
//Alias completed after the call ended: add it to that call's event when it's the same talker
if(mEndedCallEvent != null && TalkerAliasTracker.isSameRadio(mTalkerAliasTracker.getEndedCallRadio(),
mEndedCallEvent.getIdentifierCollection().getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM)))
{
MutableIdentifierCollection mic = new MutableIdentifierCollection(
mEndedCallEvent.getIdentifierCollection().getIdentifiers());
mic.update(talkerAlias);
mEndedCallEvent.setIdentifierCollection(mic);
broadcast(mEndedCallEvent);
}
}
default -> {} //Stored for the radio, for the radio's next call
}
}
/**
* Updates the current call with encryption information.
* @param embeddedEncryptionParameters decoded from the Voice Frame F
@@ -1340,6 +1376,8 @@ public class DMRDecoderState extends TimeslotDecoderState
*/
private void updateEncryptedCall(EmbeddedEncryptionParameters embeddedEncryptionParameters, boolean isGroup, long timestamp)
{
mTalkerAliasTracker.callActivity(timestamp);
if(mCurrentCallEvent != null)
{
String details = mCurrentCallEvent.getDetails();;
@@ -1377,6 +1415,9 @@ public class DMRDecoderState extends TimeslotDecoderState
*/
private void updateCurrentCall(DecodeEventType type, String details, long timestamp)
{
//Keep the talker alias matched to the talking radio before the call event copies the identifiers
mTalkerAliasTracker.callActivity(timestamp);
Event event = (mCurrentCallEvent == null ? Event.START : Event.CONTINUATION);
//Create a repeater channel descriptor if we don't have one
@@ -1428,10 +1469,12 @@ public class DMRDecoderState extends TimeslotDecoderState
{
mCurrentCallEvent.end(timestamp);
broadcast(mCurrentCallEvent);
mEndedCallEvent = mCurrentCallEvent;
mCurrentCallEvent = null;
}
getIdentifierCollection().remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
//Remembers the talker for a talker alias that completes after the call, and removes the talker alias
mTalkerAliasTracker.callEnded();
getIdentifierCollection().remove(IdentifierClass.USER, Form.TONE, Role.FROM);
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* 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
@@ -181,7 +181,10 @@ public class DMRMessageProcessor implements Listener<IMessage>
//Now that the message has been (potentially) enriched, dispatch it to the modules
dispatch(message);
//Extract the Full Link Control message fragment from the Voice with embedded signalling message
//Extract the Full Link Control message fragment from the Voice with embedded signalling message. The LCSS is
//used even when the EMB is flagged invalid: the soft decision best estimate is usually correct and the
//assembled message is validated by its own BPTC and checksum, while discarding these fragments loses valid
//FLC messages on weak signals.
if(message instanceof VoiceEMBMessage voice)
{
if(message.getTimeslot() == 1)

View File

@@ -381,6 +381,9 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
public void processChannelGrant(DMRChannel channel, IdentifierCollection identifierCollection,
Opcode opcode, long timestamp, boolean encrypted)
{
//Apply the stored talker alias for the radio, learned from the radio's earlier calls
identifierCollection = mTalkerAliasManager.enrich(identifierCollection);
mLock.lock();
try
@@ -419,11 +422,11 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
}
else if(event != null) //Update current event
{
Identifier from = getIdentifier(identifierCollection, Role.FROM);
Identifier from = getFrom(identifierCollection);
if(from != null)
{
Identifier currentFrom = getIdentifier(event.getIdentifierCollection(), Role.FROM);
Identifier currentFrom = getFrom(event.getIdentifierCollection());
if(currentFrom != null && !Objects.equals(from, currentFrom))
{
event.end(timestamp);
@@ -660,6 +663,15 @@ public class DMRTrafficChannelManager extends TrafficChannelManager implements I
* @param collection containing a TO identifier
* @return TO identifier or null
*/
/**
* FROM identifier of the collection, preferring the radio over other FROM identifiers such as a talker alias.
*/
private Identifier getFrom(IdentifierCollection collection)
{
Identifier radio = TalkerAliasManager.getFromRadio(collection);
return radio != null ? radio : getIdentifier(collection, Role.FROM);
}
private Identifier getIdentifier(IdentifierCollection collection, Role role)
{
List<Identifier> identifiers = collection.getIdentifiers(role);

View File

@@ -99,7 +99,7 @@ public class DMRAudioModule extends AmbeAudioModule implements IdentifierUpdateP
}
/**
* Processes DMR AMBE audio frames and signalling messages.
* Processes DMR AMBE audio frames and signaling messages.
*/
public void receive(IMessage message)
{
@@ -166,7 +166,7 @@ public class DMRAudioModule extends AmbeAudioModule implements IdentifierUpdateP
//Queue or process audio frames. Note: audio frames are held/queued until encrypted state is established
//before any audio is generated for the audio segment.
if(message instanceof VoiceMessage voiceMessage)
if(message instanceof VoiceMessage voiceMessage && voiceMessage.isValid())
{
if(mEncryptedCallStateEstablished && mEncryptedCall)
{
@@ -181,7 +181,12 @@ public class DMRAudioModule extends AmbeAudioModule implements IdentifierUpdateP
}
}
}
else if(message instanceof Terminator)
else if(message instanceof VoiceHeader voiceHeader && voiceHeader.isValid())
{
//Clear any queued audio frames received prior to the voice header
mQueuedAmbeFrames.clear();
}
else if(message instanceof Terminator terminator && terminator.isValid())
{
reset();
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* 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
@@ -21,127 +21,222 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.IHamming;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import io.github.dsheirer.edac.ProductCodeDecoder;
import java.util.Arrays;
/**
* Block Product Turbo Code (BPTC) base implementation for BPTC codes using simple even parity column checks.
* Block Product Turbo Code (BPTC) base implementation for BPTC codes where each row is a Hamming codeword and each
* column has even parity (ie the final row is the column parity row). Bits are ordered row by row.
*
* Uses hard decision minimum distance decoding that corrects every error pattern up to half the product code minimum
* distance and most patterns up to the maximum error count. Optionally falls back to row erasure decoding when the
* minimum distance decoder can't correct the message.
*/
public abstract class BPTCBase
{
public static int ERRORS_NOT_CORRECTED = -2;
private IHamming mHamming;
private int mColumnCount;
private int mRowCount;
private final ProductCodeDecoder mDecoder;
private final int[] mRowChecks;
private final int[] mRowErrorColumns;
private final int mColumnCount;
private final int mRowCount;
private final int mFallbackMaximumErrors;
/**
* Constructs an instance
* @param hamming to provide row checking using the correct Hamming error detection and correction algorithm.
* @param column count
* @param row count
* @param rowChecks parity check matrix column for each bit position of the row (Hamming) code, where the array
* length is the column count.
* @param rowCount number of rows, including the column parity row.
* @param maximumErrors maximum number of bit errors to correct with minimum distance decoding.
* @param fallbackMaximumErrors maximum number of bit errors to correct with row erasure decoding when the minimum
* distance decoder can't correct the message, or 0 to disable the fallback.
*/
public BPTCBase(IHamming hamming, int columnCount, int rowCount)
public BPTCBase(int[] rowChecks, int rowCount, int maximumErrors, int fallbackMaximumErrors)
{
mHamming = hamming;
mColumnCount = columnCount;
mRowChecks = rowChecks.clone();
mColumnCount = rowChecks.length;
mRowCount = rowCount;
mFallbackMaximumErrors = fallbackMaximumErrors;
int[] columnChecks = new int[rowCount];
Arrays.fill(columnChecks, 1);
mDecoder = new ProductCodeDecoder(rowChecks, columnChecks, maximumErrors);
//Single bit error locator for the row code: column for each syndrome, or -1
int combined = 0;
for(int check: rowChecks)
{
combined |= check;
}
mRowErrorColumns = new int[Integer.highestOneBit(combined) << 1];
Arrays.fill(mRowErrorColumns, -1);
for(int column = 0; column < mColumnCount; column++)
{
mRowErrorColumns[rowChecks[column]] = column;
}
}
/**
* Used by the correct() method to employ an optional attempt to correct multiple row, two-bit errors where each
* set of row errors don't shadow each other based on a test where the column cardinality is twice the row
* cardinality. This is probably not mathematically correct and should only be enabled when the decoded message
* payload has a good CRC check after the extraction (ie. only use for FLC).
* @return true if enabled, false (default) otherwise
*/
protected boolean canCorrectMultiRow2BitErrors()
{
return false;
}
/**
* Performs error detection and correction.
* Performs error detection and correction. When corrected, the corrected bit count is incremented by the number
* of corrected bits. Otherwise, the message is unchanged and the corrected bit count is set to ERRORS_NOT_CORRECTED.
* @param message to correct
* @return true if the message was corrected or false if there are uncorrectable errors.
*/
public boolean correct(CorrectedBinaryMessage message)
{
BinaryMessage columns = getColumnErrors(message);
BinaryMessage rows = getRowErrors(message);
int[] rows = new int[mRowCount];
if(columns.isEmpty() && rows.isEmpty())
for(int row = 0; row < mRowCount; row++)
{
return true;
rows[row] = getRow(row, message);
}
List<Integer> correctedIndexes = new ArrayList<>();
int corrected = mDecoder.decode(rows);
//1: fix non-shadowed single bit errors in each row
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
if(corrected == ProductCodeDecoder.UNCORRECTABLE && mFallbackMaximumErrors > 0)
{
int index = correct1BitErrors(row, message, rows);
corrected = correctRowErasure(rows);
}
if(index >= 0)
if(corrected == ProductCodeDecoder.UNCORRECTABLE)
{
message.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
return false;
}
if(corrected > 0)
{
for(int row = 0; row < mRowCount; row++)
{
correctedIndexes.add(index);
}
}
//Refresh the column errors
columns = getColumnErrors(message);
if(columns.isEmpty() && rows.isEmpty())
{
message.incrementCorrectedBitCount(correctedIndexes.size());
return true;
}
//2: fix non-shadowed multi bit errors (up to 2) in each row
if(columns.cardinality() <= 2)
{
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
List<Integer> solution = correctMultiBitErrors(row, message, columns, rows);
if(solution.size() > 0)
for(int column = 0; column < mColumnCount; column++)
{
correctedIndexes.addAll(solution);
message.set(row * mColumnCount + column, (rows[row] & (1 << column)) != 0);
}
}
message.incrementCorrectedBitCount(corrected);
}
return true;
}
/**
* Row erasure decoding fallback for messages that the minimum distance decoder can't correct (more bit errors than
* the maximum, or equidistant from more than one codeword). Corrects single bit errors in each row and then
* recomputes one row from the column parity of the other rows, choosing the row that produces the nearest
* codeword. Real-world messages can have bit errors that this corrects beyond the minimum distance decoder maximum.
* @param rows to correct, unchanged when uncorrectable.
* @return number of corrected bit errors, or UNCORRECTABLE.
*/
private int correctRowErasure(int[] rows)
{
int[] decoded = rows.clone();
int failedRow = -1;
for(int row = 0; row < mRowCount; row++)
{
int syndrome = ProductCodeDecoder.getSyndrome(rows[row], mRowChecks);
if(syndrome != 0)
{
int column = mRowErrorColumns[syndrome];
if(column >= 0)
{
decoded[row] ^= 1 << column;
}
else if(failedRow >= 0)
{
return ProductCodeDecoder.UNCORRECTABLE; //More than one row with uncorrectable errors
}
else
{
failedRow = row;
}
}
}
//3: fix single row, multi-column errors
if(rows.cardinality() == 1 && !columns.isEmpty())
{
List<Integer> solution = correctMultiBitErrors(rows.nextSetBit(0), message, columns, rows);
int[] best = null;
int bestDistance = Integer.MAX_VALUE;
boolean tie = false;
if(!solution.isEmpty())
for(int erased = 0; erased < mRowCount; erased++)
{
if(failedRow >= 0 && erased != failedRow)
{
correctedIndexes.addAll(solution);
continue;
}
int[] candidate = decoded.clone();
int parity = 0;
for(int row = 0; row < mRowCount; row++)
{
if(row != erased)
{
parity ^= candidate[row];
}
}
candidate[erased] = parity;
int distance = 0;
for(int row = 0; row < mRowCount; row++)
{
distance += Integer.bitCount(candidate[row] ^ rows[row]);
}
if(distance < bestDistance)
{
best = candidate;
bestDistance = distance;
tie = false;
}
else if(distance == bestDistance && !Arrays.equals(candidate, best))
{
tie = true;
}
}
//4: fix one or more rows that each have 2 bit errors, not shadowing each other.
if(canCorrectMultiRow2BitErrors() && rows.cardinality() > 0 && columns.cardinality() / rows.cardinality() == 2)
if(tie || bestDistance > mFallbackMaximumErrors)
{
List<Integer> solution = correctMultipleRowTwoBitErrorsNotShadowing(columns, rows, message);
return ProductCodeDecoder.UNCORRECTABLE;
}
if(!solution.isEmpty())
System.arraycopy(best, 0, rows, 0, mRowCount);
return bestDistance;
}
/**
* Row word from the message, where bit i is the bit in column i.
*/
private int getRow(int row, BinaryMessage message)
{
int word = 0;
int offset = row * mColumnCount;
for(int column = 0; column < mColumnCount; column++)
{
if(message.get(offset + column))
{
correctedIndexes.addAll(solution);
word |= 1 << column;
}
}
if(columns.isEmpty() && rows.isEmpty())
{
message.incrementCorrectedBitCount(correctedIndexes.size());
return true;
}
return word;
}
message.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
return false;
/**
* Calculates the row (Hamming) syndrome.
* @param row to check
* @param message containing bits to check
* @return syndrome, zero when the row is a valid codeword.
*/
private int getRowSyndrome(int row, BinaryMessage message)
{
return ProductCodeDecoder.getSyndrome(getRow(row, message), mRowChecks);
}
/**
@@ -161,49 +256,6 @@ public abstract class BPTCBase
return correct;
}
/**
* Calculates the column from the bit index/offset
* @param index for a bit
* @return column that contains the index
*/
public int getColumn(int index)
{
return index % mColumnCount;
}
/**
* Calculates the index for a bit that exists at the row and column.
* @param column for the bit.
* @param row for the bit
* @return index for the bit.
*/
public int getIndex(int column, int row)
{
return row * mColumnCount + column;
}
/**
* Checks the row for bit errors.
* @param row to check
* @param message containing bits to check
* @return true if the row passes the Hamming check or false if not.
*/
private boolean isRowCorrect(int row, CorrectedBinaryMessage message)
{
return mHamming.getErrorIndex(message, row * mColumnCount) == IHamming.NO_ERRORS;
}
/**
* Calculates the index for the bit error in the message
* @param row to check
* @param message with bits
* @return error index in the range 0-195, or -1 if the message row has no bit errors.
*/
private int getRowErrorIndex(int row, CorrectedBinaryMessage message)
{
return mHamming.getErrorIndex(message, row * mColumnCount);
}
/**
* Returns a bit map indicating the row errors.
* @param message containing rows.
@@ -215,7 +267,7 @@ public abstract class BPTCBase
for(int row = 0; row < mRowCount; row++)
{
rows.set(row, !isRowCorrect(row, message));
rows.set(row, getRowSyndrome(row, message) != 0);
}
return rows;
@@ -238,191 +290,6 @@ public abstract class BPTCBase
return columns;
}
/**
* Calculates the indices that make up the intersections of the row and column errors.
* @param rows bitmap indicating rows with errors
* @param columns bitmap indicating columns with errors
* @return set of intersection indices.
*/
public List<Integer> getIntersectionIndices(BinaryMessage columns, BinaryMessage rows)
{
List<Integer> intersections = new ArrayList<>();
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount; column = columns.nextSetBit(column + 1))
{
intersections.add(getIndex(column, row));
}
}
return intersections;
}
/**
* Calculates the indices that make up the intersections of the row and column errors.
* @param rows bitmap indicating rows with errors
* @param columns bitmap indicating columns with errors
* @return set of intersection indices.
*/
public List<Integer> getIntersectionIndices(BinaryMessage columns, int row)
{
List<Integer> intersections = new ArrayList<>();
for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount; column = columns.nextSetBit(column + 1))
{
intersections.add(getIndex(column, row));
}
return intersections;
}
/**
* Correct multiple rows with two-bit errors each where the error columns are not shadowing each other.
* @param columns error map
* @param rows error map
* @param message to correct
* @return corrected indices
*/
public List<Integer> correctMultipleRowTwoBitErrorsNotShadowing(BinaryMessage columns, BinaryMessage rows,
CorrectedBinaryMessage message)
{
List<Integer> solution = new ArrayList<>();
for(int row = rows.nextSetBit(0); row >= 0 && row < mRowCount; row = rows.nextSetBit(row + 1))
{
for(int column1 = columns.nextSetBit(0); column1 >= 0; column1 = columns.nextSetBit(column1 + 1))
{
for(int column2 = columns.nextSetBit(column1 + 1); column2 >= 0; column2 = columns.nextSetBit(column2 + 1))
{
int index1 = getIndex(column1, row);
int index2 = getIndex(column2, row);
message.flip(index1);
message.flip(index2);
if(isRowCorrect(row, message))
{
solution.add(index1);
solution.add(index2);
columns.clear(column1);
columns.clear(column2);
rows.clear(row);
column1 = mColumnCount;
column2 = mColumnCount;
}
else
{
message.flip(index1);
message.flip(index2);
}
}
}
}
return solution;
}
/**
* Corrects single bit errors in each row using only the Hamming error index.
* @param row to correct
* @param message with bits
* @param rows error flag set
* @return bit index that was corrected or -2 if the error was not corrected
*/
public int correct1BitErrors(int row, CorrectedBinaryMessage message, BinaryMessage rows)
{
int index = getRowErrorIndex(row, message);
if(index >= 0 && index < IHamming.MULTIPLE_ERRORS)
{
message.flip(index);
if(isRowCorrect(row, message))
{
rows.clear(row);
return index;
}
message.flip(index);
}
return ERRORS_NOT_CORRECTED;
}
/**
* Corrects multiple bit errors in a single row when all other rows are corrected first, by flipping the
* bits in the row that show errors in the columns and testing the solution.
* @param row to correct
* @param message with bits
* @param columns bit error matrix
* @param rows bit error matrix
* @return solution or an empty list if no solution.
*/
public List<Integer> correctMultiBitErrors(int row, CorrectedBinaryMessage message, BinaryMessage columns,
BinaryMessage rows)
{
//Test flipping bits in the row everywhere that we have a column error
List<Integer> testFlips = new ArrayList<>();
for(int column = columns.nextSetBit(0); column >= 0 && column < mColumnCount;
column = columns.nextSetBit(column + 1))
{
int toFlip = getIndex(column, row);
message.flip(toFlip);
testFlips.add(toFlip);
}
if(!isRowCorrect(row, message))
{
int index = getRowErrorIndex(row, message);
if(index >= 0 && index < IHamming.MULTIPLE_ERRORS)
{
message.flip(index);
if(testFlips.contains(index))
{
testFlips.removeIf(value -> value == index);
}
else
{
testFlips.add(index);
}
}
}
if(isRowCorrect(row, message))
{
//Check the columns also
boolean columnsCorrect = true;
for(Integer flip: testFlips)
{
columnsCorrect &= isColumnCorrect(getColumn(flip), message);
}
if(columnsCorrect)
{
rows.clear(row);
for(Integer flip: testFlips)
{
columns.clear(getColumn(flip));
}
return testFlips;
}
}
for(Integer flip: testFlips)
{
message.flip(flip);
}
return Collections.emptyList();
}
public void logErrorMap(CorrectedBinaryMessage message)
{
StringBuilder sb = new StringBuilder();
@@ -456,15 +323,10 @@ public abstract class BPTCBase
int offset = row * mColumnCount;
sb.append(" Row ").append(row).append(": ");
sb.append(message.getSubMessage(offset, offset + mColumnCount));
int index = getRowErrorIndex(row, message);
if(index != IHamming.NO_ERRORS)
int syndrome = getRowSyndrome(row, message);
if(syndrome != 0)
{
sb.append(" < Index:").append(index);
if(index == IHamming.MULTIPLE_ERRORS)
{
sb.append(" (2+ bit errors)");
}
sb.append(" < Syndrome:0x").append(Integer.toHexString(syndrome).toUpperCase());
}
sb.append("\n");
}

View File

@@ -22,6 +22,7 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming16;
import io.github.dsheirer.edac.ProductCodeDecoder;
import io.github.dsheirer.edac.soft.SoftBlockCode;
import io.github.dsheirer.edac.soft.SoftProductCodeDecoder;
@@ -39,10 +40,12 @@ public class BPTC_128_77 extends BPTCBase
//require correcting high reliability bits are rejected to avoid false decodes. Noise injection testing: all
//soft decisions with a cost up to 3.0 were correct, whereas most of those with a higher cost were false decodes.
private static final float SOFT_MAX_CORRECTION_COST = 3.0f;
//Maximum corrected bit count for a hard decision result. Hard decisions that correct more bits are treated as
//uncorrectable to avoid false decodes, given the weak 5-bit checksum. Noise injection testing: all hard
//decisions with more than 6 corrected bits were false decodes.
private static final int HARD_MAX_CORRECTED_BITS = 6;
//Maximum number of bit errors corrected by the hard decision minimum distance decoder. The product code minimum
//distance of 8 guarantees correction of 3 bit errors. Correcting more increases the miscorrection rate, and the
//embedded link control is only protected by a 5-bit checksum, with soft decision decoding only applied when hard
//decision decoding fails. AWGN simulation of the hard + soft decoding sequence: a maximum of 5 decoded more
//messages than the previous hard decision decoder at each noise level, with 3-5x fewer false decodes.
private static final int HARD_MAXIMUM_ERRORS = 5;
//Soft decision decoder for the Hamming(16,11,4) rows and even parity columns
private static final SoftProductCodeDecoder SOFT_DECODER = new SoftProductCodeDecoder(
SoftBlockCode.fromCodewordTest(COLUMN_COUNT, word -> Hamming16.getSyndrome(toMessage(word), 0) == 0),
@@ -68,16 +71,9 @@ public class BPTC_128_77 extends BPTCBase
public BPTC_128_77()
{
super(new Hamming16(), 16, 8);
}
/**
* Allow the base class to attempt to correct multiple rows of 2-bit errors each
*/
@Override
protected boolean canCorrectMultiRow2BitErrors()
{
return true;
//No row erasure fallback, given the weak 5-bit checksum. Recording testing: no messages were lost without it.
super(ProductCodeDecoder.getParityChecks(COLUMN_COUNT, message -> Hamming16.getSyndrome(message, 0)), ROW_COUNT,
HARD_MAXIMUM_ERRORS, 0);
}
/**
@@ -91,9 +87,8 @@ public class BPTC_128_77 extends BPTCBase
}
/**
* Performs error detection and correction and extracts the payload from the BPTC encoded message. Hard decision
* results that exceed the maximum corrected bit count are treated as uncorrectable. When the hard decision
* decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding.
* Performs error detection and correction and extracts the payload from the BPTC encoded message. When the hard
* decision decoder can't correct the message and soft bits are provided, applies iterative soft decision decoding.
* @param message with BPTC encoding.
* @param softBits for the 128 (interleaved) bits of the message where a positive value favors a 1 bit, or null.
* @return decoded message.
@@ -103,11 +98,6 @@ public class BPTC_128_77 extends BPTCBase
CorrectedBinaryMessage deinterleaved = deinterleave(message);
correct(deinterleaved);
if(deinterleaved.getCorrectedBitCount() > HARD_MAX_CORRECTED_BITS)
{
deinterleaved.setCorrectedBitCount(ERRORS_NOT_CORRECTED);
}
if(softBits != null && softBits.length == LENGTH && deinterleaved.getCorrectedBitCount() < 0)
{
CorrectedBinaryMessage softDecoded = decodeSoft(deinterleave(softBits));

View File

@@ -22,9 +22,14 @@ package io.github.dsheirer.module.decode.dmr.bptc;
import io.github.dsheirer.bits.BinaryMessage;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.edac.Hamming16;
import io.github.dsheirer.edac.ProductCodeDecoder;
/**
* Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field.
* Block Product Turbo Code 16/2 for decoding a DMR Voice Frame F payload from the 32-bit EMB field. Each of the two
* rows is the same Hamming(16,11,4) codeword, where the second row is inverted (odd column parity) for the reverse
* channel, forming a code with a minimum distance of 8.
* <p>
* Uses hard decision minimum distance decoding across both rows, which corrects up to 3 bit errors.
* <p>
* See ETSI TS 102 361-1 B.2.2.1 and B.2.2.2
*/
@@ -32,63 +37,116 @@ public class BPTC_16_2
{
private static final int[] DEINTERLEAVE = new int[]{0, 24, 1, 25, 2, 26, 3, 27, 4, 28, 5, 29, 6, 30, 7, 31, 8, 16,
9, 17, 10, 18, 11, 19, 12, 20, 13, 21, 14, 22, 15, 23};
private static final int ROW_LENGTH = 16;
private static final int ROW_MASK = 0xFFFF;
//Maximum number of bit errors to correct. This is the guaranteed correction capability for the minimum distance of
//8, so the nearest codeword is always unique. Error injection testing: corrects all 1-3 bit error patterns and
//miscorrects 3.9% of 5-bit error patterns. At a 3% bit error rate, corrects 98.5% of messages versus 93% with a
//maximum of 2.
private static final int MAXIMUM_ERRORS = 3;
private static final int[] CODEWORDS = getCodewords();
/**
* Identifies the Hamming(16,11,4) codewords where bit i of each codeword is message bit i.
*/
private static int[] getCodewords()
{
int[] checks = ProductCodeDecoder.getParityChecks(ROW_LENGTH, message -> Hamming16.getSyndrome(message, 0));
int[] codewords = new int[1 << 11];
int count = 0;
for(int word = 0; word <= ROW_MASK; word++)
{
if(ProductCodeDecoder.getSyndrome(word, checks) == 0)
{
codewords[count++] = word;
}
}
return codewords;
}
/**
* Unscramble and perform FEC checks per paragraph B.2.2.1 for Non-Reverse Channel Single Burst
*
* @param message with 32 interleaved bits.
* @return descrambled and error checked message or null if the process fails or there are too many errors.
* @return descrambled and error corrected message or null if there are too many errors.
*/
public static CorrectedBinaryMessage decodeShortBurst(CorrectedBinaryMessage message)
{
CorrectedBinaryMessage deinterleaved = deinterleave(message);
int fec = Hamming16.checkAndCorrect(deinterleaved, 0);
if(fec == 2) //0 or 1 is good, 2 = uncorrectable errors
{
return null;
}
//Check for even parity. Bits 0-15 should be the same as bits 16-31.
for(int x = 0; x < 16; x++)
{
if(deinterleaved.get(x) ^ deinterleaved.get(x + 16))
{
return null;
}
}
return deinterleaved;
return decode(deinterleave(message), false);
}
/**
* Unscramble and perform FEC checks per paragraph B.2.2.2 for Reverse Channel Single Burst
*
* @param binaryMessage with 32 interleaved bits.
* @return descrambled and error checked message or null if the process fails or there are too many errors.
* @param message with 32 interleaved bits.
* @return descrambled and error corrected message or null if there are too many errors.
*/
public static CorrectedBinaryMessage decodeReverseChannel(CorrectedBinaryMessage message)
{
CorrectedBinaryMessage deinterleaved = deinterleave(message);
System.out.println(" DEINTER: " + deinterleaved.toHexString());
int fec = Hamming16.checkAndCorrect(deinterleaved, 0);
System.out.println(" DECODED: " + deinterleaved.toHexString());
System.out.println("FEC:" + fec);
if(fec == 2) //0 or 1 is good, 2 = uncorrectable errors
return decode(deinterleave(message), true);
}
/**
* Decodes the deinterleaved message to the nearest codeword.
* @param deinterleaved message
* @param oddParity true when the second row is the inverse of the first row (reverse channel).
* @return corrected message with the corrected bit count set, or null if there are too many errors.
*/
private static CorrectedBinaryMessage decode(CorrectedBinaryMessage deinterleaved, boolean oddParity)
{
int row1 = 0;
int row2 = 0;
for(int x = 0; x < ROW_LENGTH; x++)
{
if(deinterleaved.get(x))
{
row1 |= 1 << x;
}
if(deinterleaved.get(x + ROW_LENGTH))
{
row2 |= 1 << x;
}
}
if(oddParity)
{
row2 ^= ROW_MASK;
}
int bestDistance = Integer.MAX_VALUE;
int bestCodeword = 0;
for(int codeword: CODEWORDS)
{
int distance = Integer.bitCount(codeword ^ row1) + Integer.bitCount(codeword ^ row2);
if(distance < bestDistance)
{
bestDistance = distance;
bestCodeword = codeword;
}
}
if(bestDistance > MAXIMUM_ERRORS)
{
return null;
}
//Check for odd parity. Bits 0-15 should be opposite of bits 16-31.
for(int x = 0; x < 16; x++)
int second = oddParity ? bestCodeword ^ ROW_MASK : bestCodeword;
CorrectedBinaryMessage corrected = new CorrectedBinaryMessage(ROW_LENGTH * 2);
for(int x = 0; x < ROW_LENGTH; x++)
{
if(deinterleaved.get(x) == deinterleaved.get(x + 16))
{
return null;
}
corrected.set(x, (bestCodeword & (1 << x)) != 0);
corrected.set(x + ROW_LENGTH, (second & (1 << x)) != 0);
}
return deinterleaved;
corrected.setCorrectedBitCount(bestDistance);
return corrected;
}
/**
@@ -120,6 +178,8 @@ public class BPTC_16_2
CorrectedBinaryMessage original = new CorrectedBinaryMessage(BinaryMessage.loadHex(msg));
System.out.println("ORIGINAL: " + original.toHexString());
CorrectedBinaryMessage decoded = decodeReverseChannel(original);
System.out.println(" DECODED: " + (decoded != null ? decoded.toHexString() + " corrected bits:" +
decoded.getCorrectedBitCount() : "uncorrectable"));
System.out.println("-------------------------");
}
}

Some files were not shown because too many files have changed in this diff Show More