Compare commits

..

11 Commits

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

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

Co-authored-by: Michael Chambers <michaelchambers@Michaels-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-13 04:54:08 -05:00
Denny Sheirer
73d3238644 #2634 Gradle build now uses git tag for versioning (#2635)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 03:32:08 -05:00
farewell-ladmin
4626d56730 #2392 P25P1: trust HDU/LDU2 algorithm ID over LC service options encryption flag (#2477)
Some radios set the encryption flag in the service options of the LDU1 Group
Voice Channel User link control while transmitting clear voice; the HDU and
LDU2 encryption sync parameters report ALGID 0x80 (unencrypted) for the same
call. Example (CoMIRS Boston, TG 2217, valid LCs from several radios):

  HDU   TALKGROUP:2217 UNENCRYPTED
  LDU1  GROUP VOICE CHANNEL USER FM:64008 TO:2217 SERVICE OPTIONS:PRI4 ENCRYPTED CIRCUIT
  LDU2  UNENCRYPTED

P25P1DecoderState broadcast State.ENCRYPTED for each such LDU1 and State.CALL
for each LDU2, so the channel state flapped between CALL and ENCRYPTED (and on
upstream, where ENCRYPTED cannot change back to CALL, the call stays muted).

Track the per-call encryption state from the HDU header and LDU2 algorithm ID
(reset at TDU/TDULC). When it is known to be unencrypted, clear the encryption
flag on the LC service options before using them for the channel state and
traffic channel call event. Genuinely encrypted calls are unaffected, and when
the algorithm ID is not yet known (late entry before the first LDU2) the LC
service options are used as before.

Adds VoiceServiceOptions.createUnencrypted(ServiceOptions) to copy service
options with only the encryption flag cleared.

Verified by driving P25P1DecoderState with HDU/LDU1/LDU2/TDU sequences
reproducing the field logs: state events go from C C C E C E C to all CALL;
encrypted calls (ALGID 0x84) still report ENCRYPTED throughout.


Claude-Session: https://claude.ai/code/session_01FRdfTvgcdBvwkipdfP8qxy
(cherry picked from commit 2ef3c9d02176871577d38f8afd0fa3c32fb92a58)

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-13 02:56:00 -05:00
Christopher Smith
1a90eaf6c6 P25P1: decode unconfirmed data PDUs with a packet data SAP as IP packets (#2506)
PDU header format 0x15 is the generic unconfirmed data header; unconfirmed
multi-block trunking control (UMBTC) is only its trunking control SAP
(61/63) use.  PDUHeaderFactory built a plain PDUHeader for every 0x15
header and PDUMessageFactory sent every such sequence to createUMBTC(), so
outbound unconfirmed IP data (SNDCP RF UNCONFIRMED DATA with SAP 0, 4 or
6) was logged raw as

  PDU HEADER FORMAT:UNCONFIRMED MBTC UNCONFIRMED VENDOR:STANDARD TO LLID:n
  DATA BLOCKS:4 MSG:4100450000...

while the confirmed equivalent (format 0x16) was decoded as IPPKT with
its LRRP / ARS payload.

- PDUHeaderFactory: build a PacketHeader for a 0x15 header whose SAP is
  UNENCRYPTED_USER_DATA, PACKET_DATA or SNDCP_PACKET_DATA_CONTROL.  Other
  SAPs, including trunking control 61/63, keep the plain PDUHeader.
- PDUMessageFactory: route a 0x15 sequence whose header is a PacketHeader
  through createPacketData(); everything else stays on the UMBTC path.

No payload changes are needed: PacketMessage already sizes unconfirmed
data blocks at 12 octets, and the header fields it uses (SAP, pad octet
count, data header offset) sit at the same bit positions in the confirmed
and unconfirmed headers.  The ARS RegistrationAcknowledgement parser only
reads the extension octet, so the 5-octet trailer (0x04 + 32-bit time)
some systems append does not affect it.

Adds PDUMessageFactoryTest, built from two unconfirmed PDUs captured on a
Motorola P25 Phase 1 system (ARS registration acknowledgements to UDP
port 4005, one with the trailer), plus header routing checks for SAP
0/4/6, SAP 61/63 and the confirmed 0x16 header.

Co-authored-by: csmith_ai <carrottop@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 02:42:53 -05:00
Christopher Smith
fbc54a38dd LRRP: decode request token values as 7-bit varints and correct request token names (#2507)
Motorola LRRP request tokens that carry an integer (trigger interval
0x31, trigger distance 0x78 and the unknown parameters 0x42 / 0x44)
encode it as a 7-bit variable length integer: each byte holds seven
value bits, most significant group first, and the high bit of a byte
flags a continuation byte.  The parser read a single byte, so a real
triggered location start request

  09 11 22 03 FF FF EE 52 44 64 42 9C 10 62 57 34 31 84 58

was logged as

  ID:15663103 TRIGGER ON GPIO TRIGGER EVERY:132 SECS REQUESTED TOKENS
  [TIME,REQUEST-64,REQUEST-62,HORIZONTAL DIRECTION,TIMESTAMP,
  UNRECOGNIZED(10,44,58,9C)]

and now decodes to

  ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 PARAM-44:100
  REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]

The encoding and the token names follow node-dmr-lib (LRRP.js
parseLRRPInt/toLRRPInt and its quoted TRBONet captures, DMRConst.js)
and Moto.Net (LRRPPacket.cs "using VLQ for most of the fields").

- Add VarInt, a shared 7-bit varint reader, and VarIntToken, a token
  base class whose byte length is measured from the value and bounded
  by the remaining payload.  Use them for 0x31, 0x78, 0x42 and 0x44.
- Add TokenType.fromRequestValue(): 0x34 is the periodic trigger flag
  in a request and the timestamp in a response.  TokenFactory accepts
  the resolved TokenType so the request path is not re-resolved.
- Rename request tokens: 0x31 TRIGGER_INTERVAL (TriggerInterval),
  0x4A TRIGGER_CONDITION (TriggerCondition: 02 emergency, 42 pin
  status change, 50 PTT, 51 PTT emergency), 0x78 TRIGGER_DISTANCE
  (TriggerDistance), 0x62 HORIZONTAL_SPEED.  0x42 and 0x44 become
  REQUEST_PARAMETER_42/44 (RequestParameter) with an optional varint
  value; their meaning is unknown (observed 3600 and 100).  Remove
  TriggerGpio and TriggerOnMove.
- Identity: assemble the request id big-endian (it was accumulated
  LSB-first), render it in hex, and clamp the read to the message.
- RequestedTokens: keep a list that holds only unrecognized ids; add
  getters for the recognized and unrecognized lists.
- Remove leftover debug locals from LRRPPacket.parseTokens().
- Add LRRPPacketTest (both captured bodies plus two copies with a
  corrupted type byte) and VarIntTest.

Built with the project's Gradle build (JDK 27 toolchain, in Docker);
compileJava, compileTestJava and the 9 new tests pass.

Co-authored-by: csmith_ai <carrottop@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 02:33:52 -05:00
Denny Sheirer
07b61be257 #2632 Detect on startup if multiple versions of any library are on the classpath and warn user to cleanup and reinstall (#2633)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-13 01:59:38 -05:00
Steve Pinkham
74dc32538f P25 Phase 1: fix typo in HDU Golay word starts (278 -> 378) (#2630)
GOLAY_WORD_STARTS listed 278 where it should list 378. The word at 378 was
never corrected, and the decoder ran a correction over bits 278-295 that
straddle the already corrected words at 270 and 288.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:31:08 -05:00
Steve Pinkham
7fcf035337 edac: correct Golay(24,12) words that are not at the start of the message (#2631)
Golay24.checkAndCorrect indexed the word from bit 0 (getInt(0, 22), xor(12, ...),
flip(index)) and took the parity over the whole message, so it was only right for a
message that is exactly one 24-bit word. P25 TDULC words 2-12 and the DMR voice
superframe words at 24 and 48 were "corrected" in the wrong bits. Decode any other
word in a 24-bit copy and write it back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:29:50 -05:00
Denny Sheirer
a53f6e9d99 #2628 Remove ThumbDV code support. (#2629)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-12 05:18:52 -05:00
103 changed files with 9415 additions and 3796 deletions

View File

@@ -14,6 +14,9 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
# Full history and tags, so the build can describe the commit relative to the latest version tag
fetch-depth: 0
- name: Set up JDK 27
uses: actions/setup-java@v6
@@ -34,7 +37,7 @@ jobs:
- name: Create Build Info file
run: |
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\ndescribe: $(git describe --tags --match 'v[0-9]*' --dirty)\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
- name: Add build artifacts to nightly release
uses: softprops/action-gh-release@v3

View File

@@ -1,6 +1,6 @@
# Publishes a release when a version tag is pushed. The tag has to match
# projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
# release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
# Publishes a release when a version tag is pushed: v0.6.2 for a final release,
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the
# version from the tag (see build.gradle).
name: Release
on:
@@ -22,15 +22,6 @@ jobs:
# Full history and tags, to find the previous release tag for the release notes
fetch-depth: 0
- id: version
run: |
expected="v$(sed -n 's/^projectVersion=//p' gradle.properties | tr -d '[:space:]')"
if [ "$GITHUB_REF_NAME" != "$expected" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match projectVersion in gradle.properties ($expected)"
exit 1
fi
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# Release notes list the PRs since the previous version tag (never 'nightly'). A final release
# starts from the previous final release, so it covers every alpha and beta in between.
- id: previous
@@ -54,6 +45,17 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
# The version comes from 'git describe', which can pick a different tag when more than one
# version tag points at the same commit. Fail rather than publish zips with the wrong version.
- id: version
run: |
version="$(./gradlew -q printVersion --console=plain | tail -n 1)"
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
echo "::error::Build version $version does not match tag $GITHUB_REF_NAME"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Build and test
run: ./gradlew clean build --console=plain

View File

@@ -54,17 +54,19 @@ committed to the code base, so it's not really 'nightly' as much as it is 'curre
Releases are built and published automatically by the [Release](.github/workflows/release.yml) GitHub workflow
whenever a version tag is pushed to the repository.
1. Update `projectVersion` in `gradle.properties` to the new version, following the naming convention described in
that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
2. Commit and push the change to `master`.
3. Create and push a tag that matches the version with a leading `v`:
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`,
`v0.6.2-beta-1` or `v0.6.2`):
```
git tag v0.6.2-beta-1
git push origin v0.6.2-beta-1
```
Builds made from an untagged commit get a version like `0.6.2-beta-1-14-g4626d567` (14 commits after the
`v0.6.2-beta-1` tag, at commit `4626d567`), with `-dirty` appended when there are uncommitted changes.
The workflow then:
* Verifies that the tag matches `projectVersion` in `gradle.properties` and fails if it does not.
* Verifies that the version the build derives from git matches the tag and fails if it does not.
* Builds and tests the code, then creates the release zips for Linux, Mac and Windows (x86-64 and aarch64).
* Creates a GitHub release titled `Version <version>` with auto-generated release notes (a "What's Changed" list of
the PRs merged since the previous version tag), and attaches the six zip

View File

@@ -52,8 +52,44 @@ plugins {
id 'org.beryx.runtime' version '2.0.1'
}
//Version is defined in gradle.properties so that we can CLI override it for nightly builds.
version = project.findProperty('projectVersion')
/**
* Version is derived from the most recent 'v<version>' git tag (e.g. v1.0.0-beta-1 -> 1.0.0-beta-1). Commits after
* the tag produce a version like 1.0.0-beta-1-14-g4626d567 and uncommitted changes append '-dirty'. The moving
* 'nightly' tag is ignored. Override with -PprojectVersion=<version> (the nightly build uses 'nightly' so that the
* nightly release asset names stay the same). Falls back to 'unknown' when built outside a git checkout.
*/
String gitDescribe = describeGitVersion()
version = project.findProperty('projectVersion') ?: (gitDescribe ? gitDescribe.substring(1) : 'unknown')
String describeGitVersion()
{
try
{
def describe = providers.exec {
commandLine 'git', 'describe', '--tags', '--match', 'v[0-9]*', '--dirty'
ignoreExitValue = true
}
if(describe.result.get().exitValue == 0)
{
return describe.standardOutput.asText.get().trim()
}
}
catch(Exception e)
{
logger.warn("Unable to determine version from git tags: " + e.message)
}
return null
}
tasks.register('printVersion') {
description = 'Prints the project version'
def projectVersion = version
doLast {
println projectVersion
}
}
//Java 27 is required for this version of the Project Panama preview/incubator feature
java {
@@ -202,6 +238,7 @@ jar {
attributes (
'Implementation-Title' : 'sdrtrunk project',
'Implementation-Version' : archiveVersion,
'Git-Describe' : gitDescribe ?: 'unknown',
'Created-By' : "Gradle ${gradle.gradleVersion}",
'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}",

View File

@@ -17,15 +17,16 @@
# *****************************************************************************
#
# Version naming conventions:
# Alpha: maj.min.patch-alpha-build
# Beta: maj.min.patch-beta-build
# Release: maj.min.patch
# The project version is no longer defined here. It is derived from the most recent 'v<version>' git tag (see
# build.gradle) and can be overridden with -PprojectVersion=<version>.
#
# Example: 0.6.0-beta-5
# Version tag naming conventions:
# Alpha: vmaj.min.patch-alpha-build
# Beta: vmaj.min.patch-beta-build
# Release: vmaj.min.patch
#
# Note: this is so that the file names for the resultant release build products match the GitLab release asset
# Example: v0.6.0-beta-5
#
# Note: this is so that the file names for the resultant release build products match the GitHub release asset
# tag name, specifically the dashes inserted before and after the alpha/beta labels.
# See: https://github.com/DSheirer/sdrtrunk/issues/1651
projectVersion=1.0.0-beta-1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.audio.broadcast.thinline;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
/**
* Call upload configuration for Thinline Radio.
*
* Thinline Radio accepts the same completed-call upload used by Rdio Scanner, including the system id, API key,
* and /api/call-upload path.
*/
public class ThinlineRadioConfiguration extends RdioScannerConfiguration
{
/**
* Constructor for Jackson
*/
public ThinlineRadioConfiguration()
{
this(BroadcastFormat.MP3);
}
/**
* Public constructor.
* @param format to use for audio recording (MP3)
*/
public ThinlineRadioConfiguration(BroadcastFormat format)
{
super(format);
}
@JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
@Override
public BroadcastServerType getBroadcastServerType()
{
return BroadcastServerType.THINLINE_RADIO;
}
@JsonIgnore
@Override
public String getServiceName()
{
return "Thinline Radio";
}
@Override
public BroadcastConfiguration copyOf()
{
ThinlineRadioConfiguration copy = new ThinlineRadioConfiguration();
copy.setSystemID(getSystemID());
return copy;
}
}

View File

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

View File

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

View File

@@ -68,6 +68,29 @@ public class Golay24
*/
public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex)
{
//The correction below indexes the word from bit 0 and takes the parity over the whole message, so it is only
//correct for a message that is exactly the 24-bit word. Decode any other word in a 24-bit copy.
if(startIndex != 0 || message.size() != 24)
{
CorrectedBinaryMessage word = new CorrectedBinaryMessage(24);
int original = message.getInt(startIndex, startIndex + 23);
word.load(0, 24, original);
int result = checkAndCorrectWord(word);
int corrected = word.getInt(0, 23);
message.load(startIndex, 24, corrected);
message.incrementCorrectedBitCount(Integer.bitCount(original ^ corrected));
return result;
}
return checkAndCorrectWord(message);
}
/**
* Error detection and correction of a message that is exactly one 24-bit word.
*/
private static int checkAndCorrectWord(CorrectedBinaryMessage message)
{
int startIndex = 0;
boolean parityError = message.cardinality() % 2 != 0;
int syndrome = getSyndrome(message, startIndex);

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
@@ -59,7 +59,8 @@ 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;
import io.github.dsheirer.vector.calibrate.CalibrationManager;
@@ -81,6 +82,7 @@ import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.prefs.Preferences;
import javafx.application.Platform;
@@ -153,6 +155,17 @@ public class SDRTrunk implements Listener<TunerEvent>
mApplicationLog = new ApplicationLog(mUserPreferences);
mApplicationLog.start();
Map<String,List<String>> duplicateLibraries = DuplicateLibraryDetector.findDuplicates();
if(!duplicateLibraries.isEmpty())
{
mLog.warn("More than one version of these libraries is in the library folder [" +
DuplicateLibraryDetector.getFolder(duplicateLibraries) + "]. This happens when a new version of " +
"sdrtrunk is unzipped over an older one, and it can load the older library code. Delete the " +
"sdrtrunk folder and unzip the release into an empty folder.\n" +
DuplicateLibraryDetector.describe(duplicateLibraries));
}
mResourceMonitor = new ResourceMonitor(mUserPreferences);
ThreadPool.logSettings();
@@ -219,10 +232,13 @@ public class SDRTrunk implements Listener<TunerEvent>
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
}
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
if(!headless)
{
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
}
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
mTunerManager.getDiscoveredTunerModel().addListener(this);
@@ -253,6 +269,11 @@ public class SDRTrunk implements Listener<TunerEvent>
{
updateTitle(tuner.getPreferredName());
}
if(!duplicateLibraries.isEmpty())
{
showDuplicateLibrariesDialog(duplicateLibraries);
}
}
if(calibrating && !GraphicsEnvironment.isHeadless())
@@ -622,14 +643,14 @@ public class SDRTrunk implements Listener<TunerEvent>
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
mLog.info("Stopping spectral display ...");
mSpectralPanel.dispose();
mJavaFxWindowManager.shutdown();
mLog.info("Stopping channels ...");
mPlaylistManager.getChannelProcessingManager().shutdown();
mAudioRecordingManager.stop();
mResourceMonitor.stop();
mLog.info("Stopping spectral display ...");
mSpectralPanel.clearTuner();
mLog.info("Stopping tuners ...");
mTunerManager.stop();
mLog.info("Shutdown complete.");
@@ -798,7 +819,7 @@ public class SDRTrunk implements Listener<TunerEvent>
updateTitle(null);
break;
case NOTIFICATION_SHUTTING_DOWN:
Tuner currentTuner = mSpectralPanel.getTuner();
Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null;
if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == null)
{
@@ -909,6 +930,28 @@ public class SDRTrunk implements Listener<TunerEvent>
}
/**
* Warns the user that the library folder holds more than one version of some libraries, which happens when a new
* release is unzipped over an older one. The launch scripts put every jar in the library folder on the class
* path, so the older library code can load instead of the newer code.
* @param duplicates library name to jar paths, from DuplicateLibraryDetector
*/
private void showDuplicateLibrariesDialog(Map<String,List<String>> duplicates)
{
String message = "<html><body style='width: 450px'>" +
"<p>The sdrtrunk library folder has more than one version of these libraries:</p><br>" +
"<pre>" + DuplicateLibraryDetector.describe(duplicates) + "</pre>" +
"<p>This happens when a new version of sdrtrunk is unzipped over an older one. sdrtrunk can load " +
"the older library code, which can cause crashes or errors that the newer version fixed.</p><br>" +
"<p>Close sdrtrunk, delete the sdrtrunk folder that contains this library folder and unzip the " +
"release into a new, empty folder:</p><br>" +
"<p><b>" + DuplicateLibraryDetector.getFolder(duplicates) + "</b></p><br>" +
"<p>Your playlist and settings are stored in your user folder and won't be affected.</p>" +
"</body></html>";
JOptionPane.showMessageDialog(mMainGui, message, "Duplicate Library Versions", JOptionPane.WARNING_MESSAGE);
}
/**
* Launch the application.
*/

View File

@@ -22,6 +22,8 @@ package io.github.dsheirer.gui.channel;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
import io.github.dsheirer.gui.theme.ThemeManager;
@@ -33,23 +35,14 @@ import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import io.github.dsheirer.spectrum.ComplexDftProcessor;
import io.github.dsheirer.spectrum.FrequencyOverlayPanel;
import io.github.dsheirer.spectrum.SpectrumPanel;
import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter;
import io.github.dsheirer.spectrum.converter.DFTResultsConverter;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.MouseEvent;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
@@ -63,12 +56,8 @@ import org.slf4j.LoggerFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.SpinnerNumberModel;
import javax.swing.event.MouseInputAdapter;
/**
* Display for channel FFT and squelch details
@@ -85,18 +74,20 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
private final PlaylistManager mPlaylistManager;
private ProcessingChain mProcessingChain;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
private SpectrumPanel mSpectrumPanel;
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
private final JFXPanel mSpectralPanel = new JFXPanel();
//Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
private ChannelSpectralDisplay mChannelSpectralDisplay;
private long mChannelFrequency;
private int mChannelSampleRate;
private int mChannelBandwidth;
private Long mCarrierOffset;
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
private final JLabel mTunerPPMLabel;
private final JLabel mTunerCorrectionLabel;
private final JLabel mChannelCorrectionLabel;
private final JLabel mDecoderCorrectionLabel;
private final JLabel mDummyLabel = new JLabel(" ");
private boolean mPanelVisible = false;
private boolean mDftProcessing = false;
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
@@ -108,7 +99,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
/**
* Constructs an instance.
*/
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
public ChannelSpectrumPanel(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
@@ -124,7 +115,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
JPanel labelPanel = new JPanel();
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
mTunerPPMLabel.setEnabled(false);
@@ -146,15 +137,6 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
labelPanel.add(mDummyLabel);
mNoiseFloorSpinnerModel = new SpinnerNumberModel(18, 8, 36, 1);
mNoiseFloorSpinnerModel.addChangeListener(e -> {
Number number = mNoiseFloorSpinnerModel.getNumber();
mSpectrumPanel.setSampleSize(number.doubleValue());
});
JSpinner noiseFloorSpinner = new JSpinner(mNoiseFloorSpinnerModel);
labelPanel.add(new JLabel("Noise Floor:"));
labelPanel.add(noiseFloorSpinner);
JButton logIndexesButton = new JButton("Log Settings");
logIndexesButton.addActionListener(e -> {
if(mProcessingChain != null)
@@ -199,32 +181,16 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
fftPanel.add(labelPanel, "wrap");
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
mSpectrumPanel = new SpectrumPanel(settingsManager);
mSpectrumPanel.setSampleSize(18.0);
fftPanel.add(mSpectralPanel);
ThemeManager.getInstance().registerSwing(mSpectralPanel);
/**
* The layered pane holds the overlapping spectrum and channel panels
* and manages the sizing of each panel with the resize listener
*/
JLayeredPane layeredPanel = new JLayeredPane();
layeredPanel.addComponentListener(new ResizeListener());
/**
* Create a mouse adapter to handle mouse events over the spectrum
* and waterfall panels
*/
MouseEventProcessor mouser = new MouseEventProcessor();
mFrequencyOverlayPanel.addMouseListener(mouser);
mFrequencyOverlayPanel.addMouseMotionListener(mouser);
mFrequencyOverlayPanel.addMouseWheelListener(mouser);
//Add the spectrum and channel panels to the layered panel
layeredPanel.add(mSpectrumPanel, 0, 0);
layeredPanel.add(mFrequencyOverlayPanel, 1, 0);
fftPanel.add(layeredPanel);
Platform.runLater(() -> {
ChannelSpectralDisplay display = new ChannelSpectralDisplay();
Scene scene = new Scene(display);
ThemeManager.getInstance().register(scene);
mSpectralPanel.setScene(scene);
displayCreated(display);
});
mNoiseSquelchPanel = new JFXPanel();
mSymbolPanel = new JFXPanel();
@@ -249,6 +215,8 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
}
ThemeManager.getInstance().register(scene2);
//Use the same color theme as the channel spectral display
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
mSymbolPanel.setScene(scene2);
});
@@ -258,11 +226,49 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
add(mSplitPane);
// mSplitPane.setDividerLocation(0.5);
mSampleStreamTapModule.setListener(mComplexDftProcessor);
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
mComplexDftProcessor.addConverter(DFTResultsConverter);
DFTResultsConverter.addListener(mSpectrumPanel);
mSpectrumPanel.clearSpectrum();
}
/**
* Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
*/
private synchronized void displayCreated(ChannelSpectralDisplay display)
{
mChannelSpectralDisplay = display;
if(mChannelSampleRate > 0)
{
display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth);
}
if(mCarrierOffset != null)
{
display.setCarrierOffset(mCarrierOffset);
}
updateFFTProcessing();
}
private synchronized void setChannel(long frequency, int sampleRate, int bandwidth)
{
mChannelFrequency = frequency;
mChannelSampleRate = sampleRate;
mChannelBandwidth = bandwidth;
mCarrierOffset = null;
if(mChannelSpectralDisplay != null)
{
mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth);
}
}
private synchronized void setCarrierOffset(long offset)
{
mCarrierOffset = offset;
if(mChannelSpectralDisplay != null)
{
mChannelSpectralDisplay.setCarrierOffset(offset);
}
}
/**
@@ -295,46 +301,28 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
}
/**
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
* Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
*/
private void updateFFTProcessing()
private synchronized void updateFFTProcessing()
{
if(mPanelVisible && mProcessingChain != null)
if(mChannelSpectralDisplay == null)
{
startDftProcessing();
return;
}
boolean active = mPanelVisible && mProcessingChain != null;
if(active)
{
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
}
else
{
stopDftProcessing();
}
}
/**
* Starts DFT processing
*/
private void startDftProcessing()
{
if(!mDftProcessing)
{
mDftProcessing = true;
mSampleStreamTapModule.setListener(mComplexDftProcessor);
mComplexDftProcessor.start();
}
}
/**
* Stops DFT processing
*/
private void stopDftProcessing()
{
if(mDftProcessing)
{
mSampleStreamTapModule.removeListener();
mComplexDftProcessor.stop();
mSpectrumPanel.clearSpectrum();
mDftProcessing = false;
}
mChannelSpectralDisplay.setActive(active);
}
private void broadcast(SourceEvent sourceEvent)
@@ -359,10 +347,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
mChannelCorrectionLabel.setEnabled(false);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setEnabled(false);
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
mFrequencyOverlayPanel.setChannelBandwidth(0);
setChannel(0, 0, 0);
}
/**
@@ -414,12 +399,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
if(source instanceof TunerChannelSource tcs)
{
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
}
int bandwidth = 0;
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
@@ -428,9 +408,14 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
if(!tunerChannels.isEmpty())
{
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
bandwidth = tunerChannels.getFirst().getBandwidth();
}
}
if(source instanceof TunerChannelSource tcs)
{
setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
}
}
updateFFTProcessing();
@@ -486,7 +471,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
//We se the offset cursor line to negative so that it aligns with where the signal is located
//instead of the correction that's needed to bring the signal to zero.
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
setCarrierOffset(-status.getDecoderCorrection());
});
}
}
@@ -495,52 +480,4 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
mSignalPowerView.receive(sourceEvent);
}
}
/**
* Monitors the sizing of the layered pane and resizes the spectrum and
* channel panels whenever the layered pane is resized
*/
public class ResizeListener implements ComponentListener
{
@Override public void componentResized(ComponentEvent e)
{
Component c = e.getComponent();
mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
mFrequencyOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
}
@Override public void componentHidden(ComponentEvent arg0) {}
@Override public void componentMoved(ComponentEvent arg0) {}
@Override public void componentShown(ComponentEvent arg0) {}
}
/**
* Mouse event handler for the spectral display panel.
*/
public class MouseEventProcessor extends MouseInputAdapter
{
@Override public void mouseMoved(MouseEvent event)
{
update(event);
}
/**
* Updates the cursor display while the mouse is performing actions
*/
private void update(MouseEvent event)
{
mFrequencyOverlayPanel.setCursorLocation(event.getPoint());
}
@Override public void mouseEntered(MouseEvent e)
{
mFrequencyOverlayPanel.setCursorVisible(true);
}
@Override public void mouseExited(MouseEvent e)
{
mFrequencyOverlayPanel.setCursorVisible(false);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 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
@@ -17,37 +17,36 @@
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
package io.github.dsheirer.gui.playlist.streaming;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
import io.github.dsheirer.playlist.PlaylistManager;
/**
* LRRP Trigger on Move Token
* Thinline Radio call upload configuration editor.
*
* Total Length: 1 byte
* The form and upload path match the Rdio Scanner editor.
*/
public class TriggerOnMove extends Token
public class ThinlineRadioEditor extends RdioScannerEditor
{
/**
* Constructs an instance of a heading token.
*
* @param message containing the heading
* @param offset to the start of the token
* Constructs an instance
* @param playlistManager for accessing the broadcast model
*/
public TriggerOnMove(CorrectedBinaryMessage message, int offset)
public ThinlineRadioEditor(PlaylistManager playlistManager)
{
super(message, offset);
super(playlistManager);
}
@Override
public TokenType getTokenType()
public BroadcastServerType getBroadcastServerType()
{
return TokenType.TRIGGER_ON_MOVE;
return BroadcastServerType.THINLINE_RADIO;
}
@Override
public String toString()
protected String getHostLabel()
{
return "TRIGGER ON MOVE";
return "Thinline Radio URL";
}
}

View File

@@ -0,0 +1,207 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.buffer.INativeBuffer;
import io.github.dsheirer.gui.spectrum.layer.ChannelTuningLayer;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.sample.Listener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.layout.StackPane;
/**
* Spectral display for a single decoding channel's sample stream, centered on the channel frequency. The channel
* tuning layer marks the channel passband and the decoder's estimated carrier position (PLL).
*
* Construct on the JavaFX application thread. The other methods can be called from any thread. Feed the channel's
* complex sample buffers to getSampleListener() and turn processing on with setActive(true) while the display is
* showing.
*/
public class ChannelSpectralDisplay extends DftSpectralDisplay
{
public static final String SETTINGS_PREFIX = "channel.spectral.display";
public static final double DEFAULT_SAMPLE_SIZE = 18.0;
public static final double MINIMUM_SAMPLE_SIZE = 8.0;
public static final double MAXIMUM_SAMPLE_SIZE = 36.0;
private static final String NOISE_FLOOR = "noise.floor";
private static final String MESSAGE_NO_CHANNEL = "Select a decoding channel to view its spectrum";
private final ChannelTuningLayer mTuningLayer;
private final EdgeSlider mNoiseFloorSlider;
private final Object mLock = new Object();
private boolean mActive;
private boolean mHasChannel;
/**
* Constructs an instance.
*/
public ChannelSpectralDisplay()
{
this(new ChannelTuningLayer());
}
private ChannelSpectralDisplay(ChannelTuningLayer tuningLayer)
{
super(SETTINGS_PREFIX, true, tuningLayer);
mTuningLayer = tuningLayer;
SpectralDisplayModel model = getSpectralDisplay().getModel();
getSpectralDisplay().messageProperty().set(MESSAGE_NO_CHANNEL);
//Noise floor slider along the right edge, below the configuration panel tab
mNoiseFloorSlider = new EdgeSlider("Floor", "Noise floor: moves the bottom of the spectrum's dB range. " +
"Higher settings show a deeper range and move the noise floor up the display.",
MINIMUM_SAMPLE_SIZE, MAXIMUM_SAMPLE_SIZE, 1, this::formatPlotBottom);
double sampleSize = SystemProperties.getInstance().get(key(NOISE_FLOOR), (int)DEFAULT_SAMPLE_SIZE);
sampleSize = Math.max(MINIMUM_SAMPLE_SIZE, Math.min(MAXIMUM_SAMPLE_SIZE, sampleSize));
model.sampleSizeProperty().set(sampleSize);
mNoiseFloorSlider.valueProperty().set(sampleSize);
mNoiseFloorSlider.valueProperty().addListener((ob, ov, nv) -> {
int value = (int)Math.round(nv.doubleValue());
model.sampleSizeProperty().set(value);
save(NOISE_FLOOR, value);
});
//The value shows the plot bottom in dBFS, which also depends on the DFT size and window
model.binCountProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
model.noiseBandwidthProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
mNoiseFloorSlider.refreshLabel();
StackPane.setAlignment(mNoiseFloorSlider, Pos.CENTER_RIGHT);
StackPane.setMargin(mNoiseFloorSlider, new Insets(44, 0, 8, 0));
getChildren().add(mNoiseFloorSlider);
installConfigPanel();
}
/**
* Listener for the channel's complex sample buffers.
*/
public Listener<INativeBuffer> getSampleListener()
{
return getDftProcessor();
}
/**
* Shows a channel.
* @param frequency of the channel center in Hertz
* @param sampleRate of the channel sample stream in Hertz
* @param bandwidth of the channel in Hertz, or zero to hide the passband markers
*/
public void setChannel(long frequency, int sampleRate, int bandwidth)
{
synchronized(mLock)
{
mHasChannel = sampleRate > 0;
updateProcessing();
}
getSpectralDisplay().clear();
runOnFxThread(() -> {
SpectralDisplayModel model = getSpectralDisplay().getModel();
model.resetZoom();
model.centerFrequencyProperty().set(frequency);
model.sampleRateProperty().set(sampleRate);
mTuningLayer.channelBandwidthProperty().set(bandwidth);
mTuningLayer.carrierOffsetValidProperty().set(false);
mTuningLayer.carrierOffsetProperty().set(0);
getSpectralDisplay().messageProperty().set(sampleRate > 0 ? null : MESSAGE_NO_CHANNEL);
});
}
/**
* Stops showing the channel.
*/
public void clearChannel()
{
setChannel(0, 0, 0);
}
/**
* Sets the estimated carrier offset from the channel center, from the decoder's frequency correction.
* @param offset in Hertz: where the signal is relative to the channel center
*/
public void setCarrierOffset(long offset)
{
runOnFxThread(() -> {
mTuningLayer.carrierOffsetProperty().set(offset);
mTuningLayer.carrierOffsetValidProperty().set(true);
});
}
/**
* Noise floor slider along the right edge of the display.
*/
public EdgeSlider getNoiseFloorSlider()
{
return mNoiseFloorSlider;
}
/**
* Formats the bottom of the spectrum plot in dBFS for the noise floor slider value.
*/
private String formatPlotBottom(double sampleSize)
{
double range = (20.0 * Math.log10(Math.pow(2.0, sampleSize - 1.0))) +
getSpectralDisplay().getModel().getProcessingGain();
return Math.round(-range) + " dB";
}
/**
* Turns DFT processing on while the display is showing, and off when it's hidden.
*/
public void setActive(boolean active)
{
synchronized(mLock)
{
mActive = active;
updateProcessing();
}
}
@Override
protected void processingEnabledChanged()
{
synchronized(mLock)
{
updateProcessing();
}
}
/**
* Starts or stops the DFT processor. Hold the lock when calling.
*/
private void updateProcessing()
{
if(mActive && mHasChannel && isProcessingEnabled())
{
startDft();
}
else
{
stopDft();
}
}
@Override
public void dispose()
{
setActive(false);
super.dispose();
}
}

View File

@@ -0,0 +1,49 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import javafx.beans.property.BooleanProperty;
import javafx.scene.Node;
import org.controlsfx.control.ToggleSwitch;
/**
* Labeled control that a display layer adds to its section of the configuration panel.
*
* @param label shown to the left of the control
* @param control to change the setting
*/
public record ConfigurationRow(String label, Node control)
{
private static final double SWITCH_WIDTH = 40;
/**
* Creates a row with an on/off switch bound to the property.
*/
public static ConfigurationRow toggle(String label, BooleanProperty property)
{
ToggleSwitch toggleSwitch = new ToggleSwitch();
toggleSwitch.selectedProperty().bindBidirectional(property);
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
toggleSwitch.setMinWidth(SWITCH_WIDTH);
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
return new ConfigurationRow(label, toggleSwitch);
}
}

View File

@@ -0,0 +1,423 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.gui.spectrum.layer.DecibelLayer;
import io.github.dsheirer.gui.spectrum.layer.FrequencyLayer;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.spectrum.ComplexDftProcessor;
import io.github.dsheirer.spectrum.DFTSize;
import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.layout.StackPane;
/**
* Base class for a spectral display fed by DFT processing of a complex sample stream: the spectral display with the
* decibel and frequency layers plus any layers the subclass adds, the DFT processor and dBFS converter, the
* collapsible configuration panel, and saving the display settings under a property key prefix.
*
* The color theme is shared by all spectral displays in the application.
*
* Construct on the JavaFX application thread. Subclasses call installConfigPanel() at the end of their constructor.
*/
public abstract class DftSpectralDisplay extends StackPane
{
public static final int[] FRAME_RATES = {10, 14, 16, 18, 20, 25, 30, 40, 50, 60};
public static final String PROPERTY_THEME = "spectral.display.theme";
private static final String DFT_SIZE = "dft.size";
private static final String WINDOW_TYPE = "window.type";
private static final String FRAME_RATE = "frame.rate";
private static final String AVERAGING = "averaging";
private static final String PEAK_HOLD = "peak.hold";
private static final String SPECTRUM_ENABLED = "spectrum.enabled";
private static final String WATERFALL_ENABLED = "waterfall.enabled";
private static final String DIVIDER_POSITION = "divider.position";
private static final String DECIBEL_SCALE = "decibel.scale";
private static final String CONFIG_PINNED = "config.pinned";
private static ObjectProperty<SpectralDisplayTheme> sSharedTheme;
private final SpectralDisplay mSpectralDisplay;
private final DecibelLayer mDecibelLayer = new DecibelLayer();
private final ComplexDftProcessor mDftProcessor = new ComplexDftProcessor();
private final FullScaleDecibelConverter mDecibelConverter = new FullScaleDecibelConverter();
private final String mSettingsPrefix;
private final boolean mSaveSettings;
private final ObjectProperty<DFTSize> mDftSize = new SimpleObjectProperty<>(DFTSize.FFT04096);
private final ObjectProperty<WindowType> mWindowType = new SimpleObjectProperty<>(WindowType.BLACKMAN_HARRIS_7);
private final IntegerProperty mFrameRate = new SimpleIntegerProperty(20);
private SpectralDisplayConfigPanel mConfigPanel;
private volatile boolean mProcessingEnabled;
private volatile boolean mDftRunning;
/**
* Constructs an instance.
* @param settingsPrefix for the saved setting keys, for example "spectral.display"
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
* @param layers to draw after the decibel scale and frequency layers
*/
protected DftSpectralDisplay(String settingsPrefix, boolean saveSettings, SpectralDisplayLayer... layers)
{
mSettingsPrefix = settingsPrefix;
mSaveSettings = saveSettings;
SpectralDisplayLayer[] all = new SpectralDisplayLayer[layers.length + 2];
all[0] = mDecibelLayer;
all[1] = new FrequencyLayer();
System.arraycopy(layers, 0, all, 2, layers.length);
mSpectralDisplay = new SpectralDisplay(all);
mDftProcessor.setUnityGainWindow(true);
mDftProcessor.addConverter(mDecibelConverter);
mDecibelConverter.addListener(mSpectralDisplay::receive);
//The processor starts its timer on construction - leave it stopped until there is a sample source
mDftProcessor.stop();
loadSettings();
mDftSize.addListener((ob, ov, nv) -> {
mDftProcessor.setDFTSize(nv);
mSpectralDisplay.getModel().resetZoom();
save(DFT_SIZE, nv.name());
});
mWindowType.addListener((ob, ov, nv) -> {
mDftProcessor.setWindowType(nv);
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(nv));
save(WINDOW_TYPE, nv.name());
});
mFrameRate.addListener((ob, ov, nv) -> {
mDftProcessor.setFrameRateNoSave(nv.intValue());
if(!mDftRunning)
{
//Changing the frame rate restarts the timer - keep it stopped while not processing
mDftProcessor.stop();
}
save(FRAME_RATE, nv.intValue());
});
mSpectralDisplay.averagingProperty().addListener((ob, ov, nv) -> save(AVERAGING, nv.intValue()));
mSpectralDisplay.peakHoldProperty().addListener((ob, ov, nv) -> save(PEAK_HOLD, nv));
mSpectralDisplay.dividerPositionProperty().addListener((ob, ov, nv) ->
save(DIVIDER_POSITION, String.valueOf(nv.doubleValue())));
mDecibelLayer.visibleProperty().addListener((ob, ov, nv) -> save(DECIBEL_SCALE, nv));
mSpectralDisplay.spectrumEnabledProperty().addListener((ob, ov, nv) -> {
save(SPECTRUM_ENABLED, nv);
updateProcessingEnabled();
});
mSpectralDisplay.waterfallEnabledProperty().addListener((ob, ov, nv) -> {
save(WATERFALL_ENABLED, nv);
updateProcessingEnabled();
});
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
mSpectralDisplay.waterfallEnabledProperty().get();
mSpectralDisplay.themeProperty().bindBidirectional(getSharedTheme());
getChildren().add(mSpectralDisplay);
setMinSize(0, 0);
}
/**
* Creates the configuration panel. Subclasses call this at the end of their constructor, after their own
* settings are loaded, since the panel reads the subclass's controls.
*/
protected void installConfigPanel()
{
mConfigPanel = new SpectralDisplayConfigPanel(this);
mConfigPanel.pinnedProperty().set(SystemProperties.getInstance().get(key(CONFIG_PINNED), false));
mConfigPanel.pinnedProperty().addListener((ob, ov, nv) -> save(CONFIG_PINNED, nv));
StackPane.setAlignment(mConfigPanel, Pos.TOP_RIGHT);
StackPane.setMargin(mConfigPanel, new Insets(6));
getChildren().add(mConfigPanel);
}
/**
* Color theme shared by all spectral displays, loaded from and saved to the theme setting. JavaFX thread only.
*/
public static ObjectProperty<SpectralDisplayTheme> getSharedTheme()
{
if(sSharedTheme == null)
{
SpectralDisplayTheme theme = parse(SpectralDisplayTheme.class, SystemProperties.getInstance()
.get(PROPERTY_THEME, SpectralDisplayTheme.getDefault().name()), SpectralDisplayTheme.getDefault());
sSharedTheme = new SimpleObjectProperty<>(theme);
sSharedTheme.addListener((ob, ov, nv) -> {
if(nv != null)
{
SystemProperties.getInstance().set(PROPERTY_THEME, nv.name());
}
});
}
return sSharedTheme;
}
private void loadSettings()
{
SystemProperties properties = SystemProperties.getInstance();
DFTSize dftSize = parse(DFTSize.class, properties.get(key(DFT_SIZE), DFTSize.FFT04096.name()),
DFTSize.FFT04096);
mDftSize.set(dftSize);
mDftProcessor.setDFTSize(dftSize);
WindowType windowType = parse(WindowType.class, properties.get(key(WINDOW_TYPE),
WindowType.BLACKMAN_HARRIS_7.name()), WindowType.BLACKMAN_HARRIS_7);
if(!isWindowSupported(windowType))
{
windowType = WindowType.BLACKMAN_HARRIS_7;
}
mWindowType.set(windowType);
mDftProcessor.setWindowType(windowType);
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(windowType));
int frameRate = Math.max(1, Math.min(100, properties.get(key(FRAME_RATE), mDftProcessor.getFrameRate())));
mFrameRate.set(frameRate);
mDftProcessor.setFrameRateNoSave(frameRate);
mDftProcessor.stop();
int averaging = properties.get(key(AVERAGING), SpectralDisplay.AVERAGING_DEFAULT);
mSpectralDisplay.averagingProperty().set(Math.max(SpectralDisplay.AVERAGING_MINIMUM,
Math.min(SpectralDisplay.AVERAGING_MAXIMUM, averaging)));
mSpectralDisplay.peakHoldProperty().set(properties.get(key(PEAK_HOLD), false));
mDecibelLayer.visibleProperty().set(properties.get(key(DECIBEL_SCALE), true));
try
{
double divider = Double.parseDouble(properties.get(key(DIVIDER_POSITION), "0.5"));
mSpectralDisplay.dividerPositionProperty().set(Math.max(0.05, Math.min(0.95, divider)));
}
catch(NumberFormatException nfe)
{
mSpectralDisplay.dividerPositionProperty().set(0.5);
}
mSpectralDisplay.spectrumEnabledProperty().set(properties.get(key(SPECTRUM_ENABLED), true));
mSpectralDisplay.waterfallEnabledProperty().set(properties.get(key(WATERFALL_ENABLED), true));
}
/**
* Full property key for the setting name.
*/
protected String key(String name)
{
return mSettingsPrefix + "." + name;
}
/**
* Indicates if the window type can be used for the spectral display. Kaiser windows need extra parameters.
*/
public static boolean isWindowSupported(WindowType windowType)
{
return windowType != null && windowType != WindowType.KAISER;
}
/**
* Parses an enum setting value, or returns the default for a missing or unrecognized value.
*/
protected static <E extends Enum<E>> E parse(Class<E> type, String value, E defaultValue)
{
if(value != null)
{
try
{
return Enum.valueOf(type, value);
}
catch(IllegalArgumentException iae)
{
//Fall through to the default
}
}
return defaultValue;
}
protected void save(String name, String value)
{
if(mSaveSettings)
{
SystemProperties.getInstance().set(key(name), value);
}
}
protected void save(String name, int value)
{
if(mSaveSettings)
{
SystemProperties.getInstance().set(key(name), value);
}
}
protected void save(String name, boolean value)
{
if(mSaveSettings)
{
SystemProperties.getInstance().set(key(name), value);
}
}
/**
* The spectral display.
*/
public SpectralDisplay getSpectralDisplay()
{
return mSpectralDisplay;
}
/**
* Collapsible configuration panel, or null before installConfigPanel() is called.
*/
public SpectralDisplayConfigPanel getConfigPanel()
{
return mConfigPanel;
}
/**
* DFT processor. Register it with the sample source to feed the display.
*/
protected ComplexDftProcessor getDftProcessor()
{
return mDftProcessor;
}
/**
* DFT size.
*/
public ObjectProperty<DFTSize> dftSizeProperty()
{
return mDftSize;
}
/**
* DFT window type.
*/
public ObjectProperty<WindowType> windowTypeProperty()
{
return mWindowType;
}
/**
* DFT frames per second.
*/
public IntegerProperty frameRateProperty()
{
return mFrameRate;
}
/**
* Indicates if the spectrum or the waterfall is turned on. Safe to read from any thread.
*/
protected boolean isProcessingEnabled()
{
return mProcessingEnabled;
}
private void updateProcessingEnabled()
{
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
mSpectralDisplay.waterfallEnabledProperty().get();
processingEnabledChanged();
}
/**
* Called on the JavaFX thread when the spectrum and waterfall are both turned off, or one is turned back on.
* Subclasses start or stop their sample source with isProcessingEnabled().
*/
protected abstract void processingEnabledChanged();
/**
* Starts the DFT processor timer.
*/
protected void startDft()
{
if(!mDftRunning)
{
mDftRunning = true;
mDftProcessor.clearBuffer();
mDftProcessor.start();
}
}
/**
* Stops the DFT processor timer and clears the display.
*/
protected void stopDft()
{
if(mDftRunning)
{
mDftRunning = false;
mDftProcessor.stop();
mDftProcessor.clearBuffer();
mSpectralDisplay.clear();
}
}
/**
* Indicates if the DFT processor timer is running.
*/
protected boolean isDftRunning()
{
return mDftRunning;
}
/**
* Runs the runnable on the JavaFX application thread, ignoring the request after the JavaFX toolkit has exited
* during application shutdown.
*/
protected static void runOnFxThread(Runnable runnable)
{
try
{
Platform.runLater(runnable);
}
catch(IllegalStateException ise)
{
//JavaFX toolkit has exited
}
}
/**
* Releases the DFT processing and the display. Subclasses disconnect their sample source first.
*/
public void dispose()
{
stopDft();
mDftProcessor.dispose();
mDecibelConverter.dispose();
runOnFxThread(() -> {
mSpectralDisplay.themeProperty().unbindBidirectional(getSharedTheme());
if(mConfigPanel != null)
{
mConfigPanel.dispose();
}
mSpectralDisplay.dispose();
});
}
}

View File

@@ -0,0 +1,32 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
/**
* Mouse cursor position over the spectral display.
*
* @param section under the mouse
* @param x position in pixels within the section
* @param y position in pixels within the section
* @param frequency under the mouse in Hertz
*/
public record DisplayCursor(DisplaySection section, double x, double y, long frequency)
{
}

View File

@@ -0,0 +1,29 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
/**
* Sections of the spectral display.
*/
public enum DisplaySection
{
SPECTRUM,
WATERFALL;
}

View File

@@ -0,0 +1,213 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import java.net.URL;
import java.util.function.DoubleFunction;
import javafx.animation.FadeTransition;
import javafx.animation.PauseTransition;
import javafx.beans.property.DoubleProperty;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
/**
* Vertical slider that floats along the right edge of a display. While collapsed it shows as a thin strip with a
* marker at the current value. Moving the mouse over the strip expands it into a translucent panel with a title, a
* vertical slider and the formatted value. It collapses again shortly after the mouse leaves, unless the slider is
* being dragged.
*
* Place it in a StackPane aligned to the right edge. The slider's minimum is at the bottom.
*/
public class EdgeSlider extends StackPane
{
private static final String STYLESHEET = "/spectral_display.css";
private static final double COLLAPSED_WIDTH = 14;
private static final double STRIP_WIDTH = 4;
private static final double MARKER_HEIGHT = 10;
private static final double EXPANDED_WIDTH = 64;
private static final Duration COLLAPSE_DELAY = Duration.millis(400);
private static final Duration FADE_DURATION = Duration.millis(120);
private final Slider mSlider;
private final Label mValueLabel = new Label();
private final DoubleFunction<String> mFormatter;
private final VBox mExpanded;
private final Pane mCollapsed = new Pane();
private final Region mStrip = new Region();
private final Region mMarker = new Region();
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
private boolean mIsExpanded = false;
/**
* Constructs an instance.
* @param title shown above the slider
* @param tooltip describing the control
* @param min value, at the bottom of the slider
* @param max value, at the top of the slider
* @param step between values
* @param formatter for the value shown below the slider
*/
public EdgeSlider(String title, String tooltip, double min, double max, double step,
DoubleFunction<String> formatter)
{
mFormatter = formatter;
URL stylesheet = EdgeSlider.class.getResource(STYLESHEET);
if(stylesheet != null)
{
getStylesheets().add(stylesheet.toExternalForm());
}
getStyleClass().add("spectral-edge-slider");
setMaxWidth(Region.USE_PREF_SIZE);
setMinWidth(Region.USE_PREF_SIZE);
setPickOnBounds(true);
mSlider = new Slider(min, max, min);
mSlider.setOrientation(Orientation.VERTICAL);
mSlider.setMajorTickUnit(step);
mSlider.setMinorTickCount(0);
mSlider.setBlockIncrement(step);
mSlider.setSnapToTicks(true);
mSlider.setMaxHeight(Double.MAX_VALUE);
VBox.setVgrow(mSlider, Priority.ALWAYS);
mSlider.valueProperty().addListener((ob, ov, nv) -> {
refreshLabel();
requestLayout();
});
Label titleLabel = new Label(title);
titleLabel.getStyleClass().add("spectral-edge-slider-title");
mValueLabel.getStyleClass().add("spectral-edge-slider-value");
mExpanded = new VBox(4, titleLabel, mSlider, mValueLabel);
mExpanded.setAlignment(Pos.CENTER);
mExpanded.getStyleClass().add("spectral-edge-slider-panel");
mExpanded.setPrefWidth(EXPANDED_WIDTH);
Tooltip.install(mExpanded, new Tooltip(tooltip));
mStrip.getStyleClass().add("spectral-edge-slider-strip");
mMarker.getStyleClass().add("spectral-edge-slider-marker");
//Unmanaged so the pane doesn't resize them to their (empty) preferred size - layoutChildren() places them
mStrip.setManaged(false);
mMarker.setManaged(false);
mCollapsed.getChildren().addAll(mStrip, mMarker);
mCollapsed.setPrefWidth(COLLAPSED_WIDTH);
mCollapsed.setMinWidth(COLLAPSED_WIDTH);
mCollapseTimer.setOnFinished(e -> {
if(!isHover() && !mSlider.isValueChanging() && !mSlider.isPressed())
{
setExpanded(false);
}
});
setOnMouseEntered(e -> {
mCollapseTimer.stop();
setExpanded(true);
});
setOnMouseExited(e -> mCollapseTimer.playFromStart());
mSlider.valueChangingProperty().addListener((ob, ov, nv) -> {
if(!nv && !isHover())
{
mCollapseTimer.playFromStart();
}
});
getChildren().add(mCollapsed);
refreshLabel();
}
/**
* Slider value.
*/
public DoubleProperty valueProperty()
{
return mSlider.valueProperty();
}
/**
* Updates the value text, for example when something the formatter depends on changes.
*/
public void refreshLabel()
{
mValueLabel.setText(mFormatter != null ? mFormatter.apply(mSlider.getValue()) :
String.valueOf(mSlider.getValue()));
}
/**
* Indicates if the slider panel is expanded.
*/
public boolean isExpanded()
{
return mIsExpanded;
}
/**
* Expands or collapses the slider panel.
*/
public void setExpanded(boolean expanded)
{
if(expanded != mIsExpanded)
{
mIsExpanded = expanded;
if(expanded)
{
getChildren().setAll(mExpanded);
mExpanded.setOpacity(0);
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpanded);
fade.setToValue(1.0);
fade.play();
}
else
{
getChildren().setAll(mCollapsed);
}
}
}
@Override
protected void layoutChildren()
{
super.layoutChildren();
if(!mIsExpanded)
{
//Strip along the right side of the hover area, with the marker at the value position
double width = mCollapsed.getWidth();
double height = mCollapsed.getHeight();
double stripX = width - STRIP_WIDTH - 2;
mStrip.resizeRelocate(stripX, 0, STRIP_WIDTH, height);
double range = mSlider.getMax() - mSlider.getMin();
double fraction = range > 0 ? (mSlider.getValue() - mSlider.getMin()) / range : 0;
double markerY = (1.0 - fraction) * Math.max(0, height - MARKER_HEIGHT);
mMarker.resizeRelocate(stripX - 2, markerY, STRIP_WIDTH + 4, MARKER_HEIGHT);
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
/**
* Size of a display section for rendering.
*
* @param width of the section in pixels
* @param height of the section in pixels
* @param plotHeight of the area above the bottom inset where the spectrum trace is drawn. This is the same as the
* height for the waterfall.
*/
public record RenderArea(double width, double height, double plotHeight)
{
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,596 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.spectrum.DFTSize;
import java.net.URL;
import java.util.List;
import javafx.animation.FadeTransition;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.MenuButton;
import javafx.scene.control.RadioMenuItem;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Slider;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import javafx.util.StringConverter;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.javafx.IconFontFX;
import jiconfont.javafx.IconNode;
import org.controlsfx.control.ToggleSwitch;
/**
* Translucent configuration panel that floats over the right side of the spectral display. It shows as a small
* settings tab while collapsed and expands while the mouse is over it. The pin button keeps it expanded.
*
* Controls: tuner selection, spectrum and waterfall on/off switches, waterfall pause, DFT size, window, frame rate,
* averaging, peak hold, the settings of each display layer that has settings, and the color theme.
*/
public class SpectralDisplayConfigPanel extends VBox
{
private static final String STYLESHEET = "/spectral_display.css";
private static final double PANEL_WIDTH = 360;
private static final double SWITCH_WIDTH = 40;
private static final java.text.DecimalFormat RESOLUTION_HZ_FORMAT = new java.text.DecimalFormat("0.#");
private static final java.text.DecimalFormat RESOLUTION_KHZ_FORMAT = new java.text.DecimalFormat("0.##");
private static final Duration COLLAPSE_DELAY = Duration.millis(500);
private static final Duration FADE_DURATION = Duration.millis(150);
static
{
IconFontFX.register(FontAwesome.getIconFont());
}
private final DftSpectralDisplay mHost;
private final TunerSpectralDisplay mTunerSpectralDisplay;
private final SpectralDisplay mSpectralDisplay;
private final BooleanProperty mExpanded = new SimpleBooleanProperty(false);
private final BooleanProperty mPinned = new SimpleBooleanProperty(false);
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
private final IconNode mTabIcon = new IconNode(FontAwesome.SLIDERS);
private final VBox mExpandedContent = new VBox();
private final MenuButton mTunerMenuButton = new MenuButton();
private int mPopupsShowing = 0;
/**
* Constructs an instance.
* @param host display to configure. A tuner display adds a tuner menu to the panel.
*/
public SpectralDisplayConfigPanel(DftSpectralDisplay host)
{
mHost = host;
mTunerSpectralDisplay = host instanceof TunerSpectralDisplay tunerDisplay ? tunerDisplay : null;
mSpectralDisplay = host.getSpectralDisplay();
URL stylesheet = SpectralDisplayConfigPanel.class.getResource(STYLESHEET);
if(stylesheet != null)
{
getStylesheets().add(stylesheet.toExternalForm());
}
getStyleClass().add("spectral-config-panel");
setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
setFillWidth(true);
mTabIcon.setIconSize(16);
mTabIcon.getStyleClass().add("spectral-config-icon");
buildExpandedContent();
mCollapseTimer.setOnFinished(e -> {
if(!isHover() && mPopupsShowing == 0 && !mPinned.get())
{
mExpanded.set(false);
}
});
setOnMouseEntered(e -> {
mCollapseTimer.stop();
mExpanded.set(true);
});
setOnMouseExited(e -> scheduleCollapse());
mExpanded.addListener((ob, ov, nv) -> updateExpanded());
mPinned.addListener((ob, ov, nv) -> {
if(nv)
{
mExpanded.set(true);
}
else
{
scheduleCollapse();
}
});
updateExpanded();
}
/**
* Keeps the panel expanded when true.
*/
public BooleanProperty pinnedProperty()
{
return mPinned;
}
/**
* Indicates if the panel is expanded.
*/
public BooleanProperty expandedProperty()
{
return mExpanded;
}
/**
* Releases resources.
*/
public void dispose()
{
mCollapseTimer.stop();
}
private void scheduleCollapse()
{
if(!mPinned.get())
{
mCollapseTimer.playFromStart();
}
}
/**
* Keeps the panel expanded while a combo box, menu or color picker popup is showing, since the mouse leaves the
* panel to use the popup.
*/
private void trackPopup(ReadOnlyBooleanProperty showing)
{
showing.addListener((ob, ov, nv) -> {
mPopupsShowing = Math.max(0, mPopupsShowing + (nv ? 1 : -1));
if(!nv)
{
scheduleCollapse();
}
});
}
private void updateExpanded()
{
boolean expanded = mExpanded.get() || mPinned.get();
if(expanded)
{
getChildren().setAll(mExpandedContent);
getStyleClass().remove("collapsed");
setPrefWidth(PANEL_WIDTH);
mExpandedContent.setOpacity(0);
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpandedContent);
fade.setToValue(1.0);
fade.play();
}
else
{
getChildren().setAll(mTabIcon);
if(!getStyleClass().contains("collapsed"))
{
getStyleClass().add("collapsed");
}
setPrefWidth(Region.USE_COMPUTED_SIZE);
}
}
private void buildExpandedContent()
{
//Header: icon, title and pin button
IconNode headerIcon = new IconNode(FontAwesome.SLIDERS);
headerIcon.setIconSize(14);
headerIcon.getStyleClass().add("spectral-config-icon");
Label title = new Label("Display Settings");
title.getStyleClass().add("spectral-config-title");
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
IconNode pinIcon = new IconNode(FontAwesome.THUMB_TACK);
pinIcon.setIconSize(12);
pinIcon.getStyleClass().add("spectral-config-icon");
ToggleButton pinButton = new ToggleButton(null, pinIcon);
pinButton.getStyleClass().add("spectral-config-pin");
pinButton.selectedProperty().bindBidirectional(mPinned);
pinButton.setTooltip(new Tooltip("Keep the settings panel open"));
HBox header = new HBox(6, headerIcon, title, spacer, pinButton);
header.setAlignment(Pos.CENTER_LEFT);
header.setPadding(new Insets(2, 2, 4, 4));
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(5);
grid.setPadding(new Insets(0, 8, 6, 6));
ColumnConstraints labelColumn = new ColumnConstraints();
labelColumn.setHalignment(HPos.RIGHT);
//Labels keep their full width - the controls column absorbs any shortfall
labelColumn.setMinWidth(Region.USE_PREF_SIZE);
ColumnConstraints controlColumn = new ColumnConstraints();
controlColumn.setHgrow(Priority.ALWAYS);
controlColumn.setFillWidth(true);
grid.getColumnConstraints().addAll(labelColumn, controlColumn);
int row = 0;
//Tuner
if(mTunerSpectralDisplay != null)
{
grid.add(sectionLabel("Source"), 0, row++, 2, 1);
mTunerMenuButton.setMaxWidth(Double.MAX_VALUE);
mTunerMenuButton.setOnShowing(e -> updateTunerMenu());
trackPopup(mTunerMenuButton.showingProperty());
mTunerMenuButton.textProperty().bind(Bindings.createStringBinding(() -> {
Tuner tuner = mTunerSpectralDisplay.displayedTunerProperty().get();
return tuner != null ? tuner.getPreferredName() : "None";
}, mTunerSpectralDisplay.displayedTunerProperty()));
row = addRow(grid, row, "Tuner", mTunerMenuButton);
}
//Display sections
grid.add(sectionLabel("Display"), 0, row++, 2, 1);
ToggleSwitch spectrumSwitch = toggleSwitch("Spectrum", mSpectralDisplay.spectrumEnabledProperty());
ToggleSwitch waterfallSwitch = toggleSwitch("Waterfall", mSpectralDisplay.waterfallEnabledProperty());
ToggleSwitch pauseSwitch = toggleSwitch("Pause", mSpectralDisplay.waterfallPausedProperty());
pauseSwitch.disableProperty().bind(mSpectralDisplay.waterfallEnabledProperty().not());
pauseSwitch.setTooltip(new Tooltip("Pause the waterfall"));
HBox switches = new HBox(12, labeled(spectrumSwitch), labeled(waterfallSwitch), labeled(pauseSwitch));
switches.setAlignment(Pos.CENTER);
grid.add(switches, 0, row++, 2, 1);
ComboBox<SpectralDisplayTheme> theme = comboBox(SpectralDisplayTheme.values());
theme.valueProperty().bindBidirectional(mSpectralDisplay.themeProperty());
theme.setCellFactory(listView -> new ThemeCell());
theme.setButtonCell(new ThemeCell());
row = addRow(grid, row, "Theme", theme);
//Layer settings without a section of their own
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
{
if(layer.getConfigurationTitle() == null)
{
row = addLayerRows(grid, row, layer.getConfigurationRows());
}
}
//DFT
grid.add(sectionLabel("FFT"), 0, row++, 2, 1);
ComboBox<DFTSize> dftSize = comboBox(DFTSize.values());
dftSize.setConverter(dftSizeConverter());
//Refresh the resolution shown with each size when the sample rate changes
mSpectralDisplay.getModel().sampleRateProperty().addListener((ob, ov, nv) ->
dftSize.setConverter(dftSizeConverter()));
dftSize.setTooltip(new Tooltip("FFT size and the resulting frequency resolution (bandwidth of each bin)"));
dftSize.valueProperty().bindBidirectional(mHost.dftSizeProperty());
row = addRow(grid, row, "Size", dftSize);
ComboBox<WindowType> windowType = comboBox(java.util.Arrays.stream(WindowType.values())
.filter(DftSpectralDisplay::isWindowSupported).toArray(WindowType[]::new));
windowType.valueProperty().bindBidirectional(mHost.windowTypeProperty());
row = addRow(grid, row, "Window", windowType);
ComboBox<Integer> frameRate = new ComboBox<>();
for(int rate: DftSpectralDisplay.FRAME_RATES)
{
frameRate.getItems().add(rate);
}
if(!frameRate.getItems().contains(mHost.frameRateProperty().get()))
{
frameRate.getItems().add(mHost.frameRateProperty().get());
frameRate.getItems().sort(Integer::compare);
}
frameRate.setMaxWidth(Double.MAX_VALUE);
frameRate.setConverter(converter(rate -> rate + " fps"));
frameRate.setValue(mHost.frameRateProperty().get());
frameRate.valueProperty().addListener((ob, ov, nv) -> {
if(nv != null)
{
mHost.frameRateProperty().set(nv);
}
});
trackPopup(frameRate.showingProperty());
row = addRow(grid, row, "Frame Rate", frameRate);
Slider averaging = new Slider(SpectralDisplay.AVERAGING_MINIMUM, SpectralDisplay.AVERAGING_MAXIMUM,
mSpectralDisplay.averagingProperty().get());
averaging.setBlockIncrement(1);
averaging.setMajorTickUnit(1);
averaging.setMinorTickCount(0);
averaging.setSnapToTicks(true);
averaging.valueProperty().addListener((ob, ov, nv) ->
mSpectralDisplay.averagingProperty().set((int)Math.round(nv.doubleValue())));
HBox.setHgrow(averaging, Priority.ALWAYS);
Label fast = new Label("Fast");
fast.getStyleClass().add("spectral-config-hint");
Label slow = new Label("Slow");
slow.getStyleClass().add("spectral-config-hint");
HBox averagingBox = new HBox(4, fast, averaging, slow);
averagingBox.setAlignment(Pos.CENTER_LEFT);
Tooltip.install(averagingBox, new Tooltip("Spectrum averaging: signals rise quickly and fade slowly. " +
"Slower settings give a steadier noise floor."));
row = addRow(grid, row, "Averaging", averagingBox);
ConfigurationRow peakHold = ConfigurationRow.toggle("Peak Hold", mSpectralDisplay.peakHoldProperty());
Tooltip.install(peakHold.control(), new Tooltip("Show a line at the recent peak of each frequency, " +
"falling slowly"));
row = addRow(grid, row, peakHold.label(), peakHold.control());
//Layer settings
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
{
List<ConfigurationRow> rows = layer.getConfigurationRows();
if(layer.getConfigurationTitle() != null && rows != null && !rows.isEmpty())
{
grid.add(sectionLabel(layer.getConfigurationTitle()), 0, row++, 2, 1);
row = addLayerRows(grid, row, rows);
}
}
VBox content = new VBox(4, grid);
content.setPadding(new Insets(0, 0, 4, 0));
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.getStyleClass().add("spectral-config-scroll");
//Limit the panel to the display height and scroll the controls when they don't fit
scrollPane.prefViewportHeightProperty().bind(content.heightProperty());
scrollPane.maxHeightProperty().bind(Bindings.max(40, mHost.heightProperty()
.subtract(header.heightProperty()).subtract(24)));
mExpandedContent.getChildren().addAll(header, scrollPane);
}
/**
* Converter that labels each DFT size with its bin bandwidth at the current sample rate.
*/
private StringConverter<DFTSize> dftSizeConverter()
{
return converter(size -> {
int sampleRate = mSpectralDisplay.getModel().getSampleRate();
if(sampleRate <= 0)
{
return size.getLabel();
}
return size.getLabel() + " \u00B7 " + formatResolution((double)sampleRate / size.getSize());
});
}
/**
* Formats a bin bandwidth in Hz or kHz.
*/
static String formatResolution(double hertz)
{
if(hertz >= 1000.0)
{
return RESOLUTION_KHZ_FORMAT.format(hertz / 1000.0) + " kHz/bin";
}
return RESOLUTION_HZ_FORMAT.format(hertz) + " Hz/bin";
}
/**
* Rebuilds the tuner menu with the currently available tuners.
*/
private void updateTunerMenu()
{
mTunerMenuButton.getItems().clear();
ToggleGroup group = new ToggleGroup();
Tuner current = mTunerSpectralDisplay.displayedTunerProperty().get();
List<Tuner> tuners = mTunerSpectralDisplay.getAvailableTuners();
for(Tuner tuner: tuners)
{
RadioMenuItem item = new RadioMenuItem(tuner.getPreferredName());
item.setToggleGroup(group);
item.setSelected(tuner == current);
item.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(tuner));
mTunerMenuButton.getItems().add(item);
}
if(!tuners.isEmpty())
{
mTunerMenuButton.getItems().add(new SeparatorMenuItem());
}
RadioMenuItem none = new RadioMenuItem("None");
none.setToggleGroup(group);
none.setSelected(current == null);
none.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(null));
mTunerMenuButton.getItems().add(none);
}
/**
* Adds a layer's configuration rows to the grid.
* @return next row index
*/
private int addLayerRows(GridPane grid, int row, List<ConfigurationRow> rows)
{
if(rows != null)
{
for(ConfigurationRow configurationRow: rows)
{
if(configurationRow.control() instanceof ComboBox<?> comboBox)
{
trackPopup(comboBox.showingProperty());
}
row = addRow(grid, row, configurationRow.label(), configurationRow.control());
}
}
return row;
}
private int addRow(GridPane grid, int row, String label, Node control)
{
grid.add(new Label(label), 0, row);
grid.add(control, 1, row);
GridPane.setHgrow(control, Priority.ALWAYS);
return row + 1;
}
private static Label sectionLabel(String text)
{
Label label = new Label(text);
label.getStyleClass().add("spectral-config-section");
return label;
}
/**
* Places the switch's text in a label to the left of the switch. The switch's own text adds a wide gap.
*/
private static HBox labeled(ToggleSwitch toggleSwitch)
{
Label label = new Label(toggleSwitch.getText());
label.setMinWidth(Region.USE_PREF_SIZE);
label.disableProperty().bind(toggleSwitch.disabledProperty());
label.setOnMouseClicked(e -> {
if(!toggleSwitch.isDisabled())
{
toggleSwitch.setSelected(!toggleSwitch.isSelected());
}
});
toggleSwitch.setText(null);
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
toggleSwitch.setMinWidth(SWITCH_WIDTH);
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
HBox box = new HBox(4, label, toggleSwitch);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static ToggleSwitch toggleSwitch(String text, BooleanProperty property)
{
ToggleSwitch toggleSwitch = new ToggleSwitch(text);
toggleSwitch.selectedProperty().bindBidirectional(property);
return toggleSwitch;
}
@SafeVarargs
private <T> ComboBox<T> comboBox(T... values)
{
ComboBox<T> comboBox = new ComboBox<>();
comboBox.getItems().addAll(values);
comboBox.setMaxWidth(Double.MAX_VALUE);
trackPopup(comboBox.showingProperty());
return comboBox;
}
private static <T> StringConverter<T> converter(java.util.function.Function<T,String> function)
{
return new StringConverter<>()
{
@Override
public String toString(T value)
{
return value != null ? function.apply(value) : "";
}
@Override
public T fromString(String string)
{
return null;
}
};
}
/**
* Theme list cell with a preview swatch: the spectrum background, a channel highlight and the waterfall palette.
*/
private static class ThemeCell extends javafx.scene.control.ListCell<SpectralDisplayTheme>
{
private static final double SWATCH_WIDTH = 44;
private static final double SWATCH_HEIGHT = 12;
@Override
protected void updateItem(SpectralDisplayTheme theme, boolean empty)
{
super.updateItem(theme, empty);
if(empty || theme == null)
{
setText(null);
setGraphic(null);
return;
}
setText(theme.getLabel());
setGraphic(createSwatch(theme));
setGraphicTextGap(8);
}
private static Node createSwatch(SpectralDisplayTheme theme)
{
javafx.scene.canvas.Canvas canvas = new javafx.scene.canvas.Canvas(SWATCH_WIDTH, SWATCH_HEIGHT);
javafx.scene.canvas.GraphicsContext gc = canvas.getGraphicsContext2D();
double half = SWATCH_WIDTH / 2.0;
//Left half: spectrum background, trace fill and a processing channel highlight
gc.setFill(theme.getBackground());
gc.fillRect(0, 0, half, SWATCH_HEIGHT);
gc.setFill(theme.getChannelProcessing());
gc.fillRect(half * 0.55, 0, half * 0.3, SWATCH_HEIGHT);
gc.setFill(theme.getGradientBottom());
gc.fillRect(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.35);
gc.setStroke(theme.getTrace());
gc.strokeLine(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.65);
//Right half: waterfall palette from the noise floor to the strongest signal
for(int x = 0; x < half; x++)
{
int index = (int)Math.round(x / (half - 1) * (WaterfallPalette.SIZE - 1));
gc.setFill(WaterfallPalette.toColor(theme.getWaterfallColor(index)));
gc.fillRect(half + x, 0, 1, SWATCH_HEIGHT);
}
gc.setStroke(javafx.scene.paint.Color.gray(0.5, 0.8));
gc.strokeRect(0.5, 0.5, SWATCH_WIDTH - 1, SWATCH_HEIGHT - 1);
return canvas;
}
}
}

View File

@@ -0,0 +1,162 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import java.util.Collections;
import java.util.List;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.MenuItem;
/**
* Optional layer that draws on the spectral display. The display calls the render methods on the JavaFX application
* thread each time it redraws, in the order the layers were added. Override only the methods the layer needs.
*
* Spectrum drawing order: background color, each layer's background, spectrum trace, each layer's foreground.
* Waterfall drawing order: waterfall image, each layer's waterfall overlay.
*/
public abstract class SpectralDisplayLayer
{
private SpectralDisplay mSpectralDisplay;
/**
* Called by the display when the layer is added.
*/
void attach(SpectralDisplay spectralDisplay)
{
mSpectralDisplay = spectralDisplay;
attached();
}
/**
* Called by the display when the layer is removed.
*/
void detach()
{
detached();
mSpectralDisplay = null;
}
/**
* Hook for subclasses after the layer is added to a display.
*/
protected void attached()
{
}
/**
* Hook for subclasses before the layer is removed from a display.
*/
protected void detached()
{
}
/**
* Display this layer is added to, or null.
*/
protected SpectralDisplay getSpectralDisplay()
{
return mSpectralDisplay;
}
/**
* Frequency model of the display this layer is added to, or null.
*/
protected SpectralDisplayModel getModel()
{
return mSpectralDisplay != null ? mSpectralDisplay.getModel() : null;
}
/**
* Asks the display to redraw on the next frame. Safe to call from any thread.
*/
protected void requestRender()
{
if(mSpectralDisplay != null)
{
mSpectralDisplay.requestRender();
}
}
/**
* Pixels this layer needs along the bottom of the spectrum section, below the spectrum plot. The display reserves
* the largest inset that any layer asks for.
*/
public double getSpectrumBottomInset()
{
return 0;
}
/**
* Draws behind the spectrum trace.
*/
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
{
}
/**
* Draws on top of the spectrum trace.
*/
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
}
/**
* Draws on top of the waterfall.
*/
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
}
/**
* Context menu items for a right-click at the frequency.
* @param section that was clicked
* @param frequency under the mouse
* @return menu items, or an empty list
*/
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
{
return Collections.emptyList();
}
/**
* Title of this layer's section in the configuration panel, or null to place the layer's rows in the panel's
* Display section instead of a section of their own.
*/
public String getConfigurationTitle()
{
return getClass().getSimpleName();
}
/**
* Labeled controls for this layer's section of the configuration panel, or an empty list if the layer has no
* settings.
*/
public List<ConfigurationRow> getConfigurationRows()
{
return Collections.emptyList();
}
/**
* Releases resources and listeners.
*/
public void dispose()
{
}
}

View File

@@ -0,0 +1,316 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
/**
* Frequency and zoom state shared by the spectrum, the waterfall and the display layers, with the conversions
* between frequency, DFT bin and screen position. Access this model only on the JavaFX application thread.
*
* DFT bins are in display order (after the half swap) so bin 0 is the lowest frequency and bin (size / 2) is the
* center frequency. The center of bin i is at minimum frequency + (i * bin width). The display shows the bins
* [zoom offset, zoom offset + visible bin count) and each visible bin covers an equal share of the screen width.
*/
public class SpectralDisplayModel
{
public static final int MIN_ZOOM = 0;
public static final int MAX_ZOOM = 6;
private final LongProperty mCenterFrequency = new SimpleLongProperty(0);
private final IntegerProperty mSampleRate = new SimpleIntegerProperty(0);
private final IntegerProperty mBinCount = new SimpleIntegerProperty(4096);
private final IntegerProperty mZoom = new SimpleIntegerProperty(MIN_ZOOM);
private final DoubleProperty mZoomOffset = new SimpleDoubleProperty(0);
private final DoubleProperty mSampleSize = new SimpleDoubleProperty(16.0);
private final DoubleProperty mNoiseBandwidth = new SimpleDoubleProperty(0);
/**
* Constructs an instance.
*/
public SpectralDisplayModel()
{
}
/**
* Center frequency of the source in Hertz.
*/
public LongProperty centerFrequencyProperty()
{
return mCenterFrequency;
}
public long getCenterFrequency()
{
return mCenterFrequency.get();
}
/**
* Sample rate (bandwidth) of the source in Hertz.
*/
public IntegerProperty sampleRateProperty()
{
return mSampleRate;
}
public int getSampleRate()
{
return mSampleRate.get();
}
/**
* Number of DFT bins in each result set.
*/
public IntegerProperty binCountProperty()
{
return mBinCount;
}
public int getBinCount()
{
return mBinCount.get();
}
/**
* Sets the bin count and keeps the same frequency at the left edge of the zoom window.
*/
public void setBinCount(int binCount)
{
if(binCount > 0 && binCount != getBinCount())
{
double ratio = getZoomOffset() / (double)getBinCount();
mBinCount.set(binCount);
setZoomOffset(ratio * binCount);
}
}
/**
* Zoom level where the zoom multiplier is 2 ^ zoom.
*/
public IntegerProperty zoomProperty()
{
return mZoom;
}
public int getZoom()
{
return mZoom.get();
}
public int getZoomMultiplier()
{
return 1 << getZoom();
}
/**
* Index of the first DFT bin shown at the left edge of the display.
*/
public DoubleProperty zoomOffsetProperty()
{
return mZoomOffset;
}
public double getZoomOffset()
{
return mZoomOffset.get();
}
/**
* Sets the zoom window offset, limited so the zoom window stays inside the DFT bins.
*/
public void setZoomOffset(double offset)
{
double max = getBinCount() - getVisibleBinCount();
mZoomOffset.set(Math.max(0, Math.min(max, offset)));
}
/**
* Source sample size in bits, which sets the dB range of the spectrum plot.
*/
public DoubleProperty sampleSizeProperty()
{
return mSampleSize;
}
/**
* Equivalent noise bandwidth of the DFT window, in bins, for dBFS data. When set, the spectrum plot extends
* below the sample dynamic range by the DFT processing gain, since the noise in each bin is that much lower than
* the total noise. Zero (the default) for data that doesn't include the processing gain.
*/
public DoubleProperty noiseBandwidthProperty()
{
return mNoiseBandwidth;
}
/**
* DFT processing gain in dB: 10 * log10(bin count / window noise bandwidth), or zero when the noise bandwidth is
* not set.
*/
public double getProcessingGain()
{
double noiseBandwidth = mNoiseBandwidth.get();
return noiseBandwidth > 0 ? 10.0 * Math.log10(getBinCount() / noiseBandwidth) : 0.0;
}
/**
* Range of the spectrum plot in dB below full scale. The bottom of the plot is at minus this value. The range is
* the dynamic range of the source sample size plus the DFT processing gain.
*/
public double getDynamicRange()
{
return 20.0 * Math.log10(Math.pow(2.0, mSampleSize.get() - 1.0)) + getProcessingGain();
}
/**
* Indicates if a source frequency and sample rate are set.
*/
public boolean hasSource()
{
return getSampleRate() > 0;
}
/**
* Number of DFT bins in the zoom window.
*/
public int getVisibleBinCount()
{
return Math.max(1, getBinCount() / getZoomMultiplier());
}
/**
* Width of each DFT bin in Hertz.
*/
public double getBinWidth()
{
return (double)getSampleRate() / (double)getBinCount();
}
/**
* Lowest frequency of the source.
*/
public long getMinFrequency()
{
return getCenterFrequency() - (getSampleRate() / 2);
}
/**
* Highest frequency of the source.
*/
public long getMaxFrequency()
{
return getCenterFrequency() + (getSampleRate() / 2);
}
/**
* Frequency at the left edge of the display.
*/
public double getDisplayMinFrequency()
{
return getMinFrequency() + ((getZoomOffset() - 0.5) * getBinWidth());
}
/**
* Frequency at the right edge of the display.
*/
public double getDisplayMaxFrequency()
{
return getDisplayMinFrequency() + getDisplayBandwidth();
}
/**
* Bandwidth shown across the display, in Hertz.
*/
public double getDisplayBandwidth()
{
return getVisibleBinCount() * getBinWidth();
}
/**
* Screen x position of the frequency.
* @param frequency in Hertz
* @param width of the display in pixels
*/
public double getX(double frequency, double width)
{
double bandwidth = getDisplayBandwidth();
if(bandwidth <= 0)
{
return 0;
}
return (frequency - getDisplayMinFrequency()) / bandwidth * width;
}
/**
* Frequency at the screen x position.
* @param x position in pixels
* @param width of the display in pixels
*/
public long getFrequency(double x, double width)
{
if(width <= 0)
{
return getCenterFrequency();
}
return Math.round(getDisplayMinFrequency() + (x / width * getDisplayBandwidth()));
}
/**
* Pixel width of one DFT bin at the current zoom.
*/
public double getPixelsPerBin(double width)
{
return width / getVisibleBinCount();
}
/**
* Changes the zoom level and keeps the frequency at the same screen position.
* @param zoom level, limited to MIN_ZOOM - MAX_ZOOM
* @param frequency to hold in place
* @param xFraction screen position of the frequency as a fraction (0 - 1.0) of the display width
*/
public void setZoom(int zoom, long frequency, double xFraction)
{
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
if(zoom != getZoom())
{
double bin = hasSource() ? ((frequency - getMinFrequency()) / getBinWidth()) + 0.5 : getBinCount() / 2.0;
mZoom.set(zoom);
setZoomOffset(bin - (xFraction * getVisibleBinCount()));
}
}
/**
* Resets the zoom to the full source bandwidth.
*/
public void resetZoom()
{
mZoom.set(MIN_ZOOM);
setZoomOffset(0);
}
}

View File

@@ -0,0 +1,190 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import io.github.dsheirer.util.ThreadPool;
import java.awt.EventQueue;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
/**
* Swing panel that holds the JavaFX tuner spectral display for the main application window.
*
* The JavaFX display is created on the JavaFX application thread after construction. A tuner requested before the
* display exists is shown once the display is ready.
*/
public class SpectralDisplayPanel extends JFXPanel
{
private final DiscoveredTunerModel mDiscoveredTunerModel;
private TunerSpectralDisplay mTunerSpectralDisplay;
private Tuner mPendingTuner;
private boolean mDisposed;
/**
* Constructs an instance.
* @param playlistManager for the channels
* @param discoveredTunerModel for the list of tuners
*/
public SpectralDisplayPanel(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel)
{
mDiscoveredTunerModel = discoveredTunerModel;
Platform.setImplicitExit(false);
Platform.runLater(() -> {
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, true);
display.setTunerSelectionHandler(this::tunerSelected);
Scene scene = new Scene(display);
ThemeManager.getInstance().register(scene);
setScene(scene);
Tuner pending;
synchronized(SpectralDisplayPanel.this)
{
if(mDisposed)
{
ThreadPool.CACHED.submit(display::dispose);
return;
}
mTunerSpectralDisplay = display;
pending = mPendingTuner;
mPendingTuner = null;
}
if(pending != null)
{
ThreadPool.CACHED.submit(() -> display.showTuner(pending));
}
});
}
/**
* Handles a tuner selection in the configuration panel. Selecting a tuner turns the main spectral display on and
* selecting no tuner turns it off, the same as the View menu.
*/
private void tunerSelected(Tuner tuner)
{
//Broadcast on the Swing event thread, the same as the Swing tuner menu items, since listeners update Swing
EventQueue.invokeLater(() -> {
if(tuner != null)
{
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true);
mDiscoveredTunerModel.broadcast(new TunerEvent(tuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY));
}
else
{
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false);
mDiscoveredTunerModel.broadcast(new TunerEvent(getTuner(),
TunerEvent.Event.REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY));
}
});
}
/**
* Shows the tuner in the display. Can block while the tuner starts its sample stream.
*/
public void showTuner(Tuner tuner)
{
TunerSpectralDisplay display;
synchronized(this)
{
if(mTunerSpectralDisplay == null)
{
mPendingTuner = tuner;
return;
}
display = mTunerSpectralDisplay;
}
display.showTuner(tuner);
}
/**
* Stops showing the current tuner.
*/
public void clearTuner()
{
TunerSpectralDisplay display;
synchronized(this)
{
if(mTunerSpectralDisplay == null)
{
mPendingTuner = null;
return;
}
display = mTunerSpectralDisplay;
}
display.clearTuner();
}
/**
* Currently displayed tuner, or null.
*/
public Tuner getTuner()
{
TunerSpectralDisplay display;
synchronized(this)
{
if(mTunerSpectralDisplay == null)
{
return mPendingTuner;
}
display = mTunerSpectralDisplay;
}
return display.getTuner();
}
/**
* Stops showing the tuner and releases resources.
*/
public void dispose()
{
TunerSpectralDisplay display;
synchronized(this)
{
mDisposed = true;
display = mTunerSpectralDisplay;
mTunerSpectralDisplay = null;
mPendingTuner = null;
}
if(display != null)
{
display.dispose();
}
}
}

View File

@@ -0,0 +1,263 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import javafx.scene.paint.Color;
/**
* Color themes for the spectral display. Each theme sets every display color and the waterfall palette, so the user
* picks one theme instead of setting each color.
*
* Color blind safe themes use perceptually uniform waterfall palettes (viridis, cividis, inferno) that keep an even
* brightness ramp for each type of color vision deficiency, and Okabe-Ito colors for the channel highlights, which
* also differ in brightness so they don't rely on hue alone.
*/
public enum SpectralDisplayTheme
{
CLASSIC("Classic", Group.STANDARD,
"#000000", "#FFFFFF80", "#00FF0080", "#FFFFFF80", "#C0C0C080", "#FFC800",
"#C0C0C03C", "#00FF003C", "#0000FF3C", "#FFFF00",
WaterfallPalette.classic()),
DARK("Dark", Group.STANDARD,
"#121417", "#64B5F68C", "#1565C059", "#90CAF9E6", "#8A939ECC", "#FFCA28",
"#9AA4B138", "#26A69A59", "#7E57C273", "#FFEE58",
WaterfallPalette.gradient("#0B0E24", "#152A6B", "#1F5FA8", "#2BA3B8", "#7BD389", "#F2E35B", "#F28C38",
"#E8443A")),
MEDIUM("Medium", Group.STANDARD,
"#3A3F47", "#E0E6EE99", "#8FA8C866", "#F0F4F8E6", "#C4CCD6CC", "#FFB74D",
"#C4CCD640", "#66BB6A66", "#42A5F573", "#FFF176",
WaterfallPalette.gradient("#2A2F38", "#31527A", "#3C8DAD", "#7BC8A4", "#F2D06B", "#F08A4B", "#E04F5F")),
LIGHT("Light", Group.STANDARD,
"#F7F8FA", "#1E88E559", "#90CAF940", "#1565C0", "#5F6B7AE6", "#D84315",
"#607D8B2E", "#43A0474D", "#1E88E54D", "#E65100",
WaterfallPalette.gradient("#FFFFFF", "#DCEBF7", "#9CC9E8", "#4F9BD0", "#1F6AA5", "#0B3C73", "#061A40")),
GREEN_PHOSPHOR("Green Phosphor", Group.MONOCHROME,
"#000A00", "#33FF6673", "#00661A59", "#66FF8C", "#2E9E4AD9", "#CCFFCC",
"#1F7A354D", "#33FF664D", "#B3FFC666", "#CCFFCC",
WaterfallPalette.gradient("#000A00", "#00290A", "#005C1A", "#00A332", "#33FF66", "#CCFFD9")),
AMBER("Amber", Group.MONOCHROME,
"#0D0700", "#FFB00073", "#7A4A0059", "#FFC233", "#B07A10D9", "#FFF0C2",
"#8A5A004D", "#FFB00047", "#FFE08A66", "#FFF0C2",
WaterfallPalette.gradient("#0D0700", "#2E1A00", "#5C3600", "#A66300", "#FFB000", "#FFE6A6")),
GRAYSCALE("Grayscale", Group.MONOCHROME,
"#000000", "#FFFFFF80", "#60606066", "#FFFFFFD9", "#A0A0A0D9", "#FFFFFF",
"#80808040", "#C0C0C04D", "#FFFFFF66", "#FFFFFF",
WaterfallPalette.gradient("#000000", "#404040", "#808080", "#C0C0C0", "#FFFFFF")),
HIGH_CONTRAST_DARK("High Contrast Dark", Group.HIGH_CONTRAST,
"#000000", "#FFFFFFBF", "#FFFFFF59", "#FFFFFF", "#FFFFFF", "#FFFF00",
"#FFFFFF2E", "#00FF0059", "#00FFFF66", "#FF00FF",
WaterfallPalette.gradient("#000000", "#0000FF", "#00FFFF", "#FFFF00", "#FFFFFF")),
HIGH_CONTRAST_LIGHT("High Contrast Light", Group.HIGH_CONTRAST,
"#FFFFFF", "#00000099", "#00000040", "#000000", "#000000", "#D00000",
"#0000001F", "#0080004D", "#0000FF4D", "#C000C0",
WaterfallPalette.gradient("#FFFFFF", "#FFFF00", "#FF8000", "#C00000", "#000000")),
VIRIDIS("Viridis (red/green color blind)", Group.COLOR_BLIND,
"#0A0A12", "#FDE72573", "#21918C59", "#FDE725", "#A0A0B0D9", "#FFFFFF",
"#A0A0A038", "#0072B273", "#E69F0073", "#CC79A7",
WaterfallPalette.gradient("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779",
"#6DCD59", "#B4DE2C", "#FDE725")),
CIVIDIS("Cividis (red/green color blind)", Group.COLOR_BLIND,
"#00122E", "#FEE83873", "#575D6D66", "#FEE838", "#A9B0BFD9", "#FFFFFF",
"#A9B0BF38", "#56B4E966", "#E69F0073", "#CC79A7",
WaterfallPalette.gradient("#00224E", "#123570", "#3B496C", "#575D6D", "#707173", "#8A8779", "#A69D75",
"#C4B56C", "#E4CF5B", "#FEE838")),
INFERNO("Inferno (blue/yellow color blind)", Group.COLOR_BLIND,
"#000004", "#FCFFA473", "#BC375466", "#FCFFA4", "#B0A8B8D9", "#FFFFFF",
"#A0A0A038", "#D55E0073", "#56B4E973", "#FFFFFF",
WaterfallPalette.gradient("#000004", "#1B0C41", "#4A0C6B", "#781C6D", "#A52C60", "#CF4446", "#ED6925",
"#FB9B06", "#F7D13D", "#FCFFA4"));
/**
* Theme groups, for ordering and labeling in the theme selection list.
*/
public enum Group
{
STANDARD,
MONOCHROME,
HIGH_CONTRAST,
COLOR_BLIND;
}
private final String mLabel;
private final Group mGroup;
private final Color mBackground;
private final Color mGradientTop;
private final Color mGradientBottom;
private final Color mTrace;
private final Color mLine;
private final Color mCursor;
private final Color mChannel;
private final Color mChannelProcessing;
private final Color mChannelSelected;
private final Color mCorrection;
private final int[] mWaterfallPalette;
/**
* Constructs a theme. Colors are web color strings with an optional alpha byte (#RRGGBBAA).
* @param label to show to the user
* @param group the theme belongs to
* @param background of the spectrum
* @param gradientTop of the spectrum fill, at the middle of the spectrum height
* @param gradientBottom of the spectrum fill, at the bottom of the spectrum
* @param trace line along the top of the spectrum fill
* @param line color of the axis, grid lines and text
* @param cursor line and readout color
* @param channel fill for channels that are not processing
* @param channelProcessing fill for processing channels
* @param channelSelected fill for the selected channel
* @param correction color of the channel frequency correction marker
* @param waterfallPalette 256 ARGB colors from the noise floor to the strongest signal
*/
SpectralDisplayTheme(String label, Group group, String background, String gradientTop, String gradientBottom,
String trace, String line, String cursor, String channel, String channelProcessing,
String channelSelected, String correction, int[] waterfallPalette)
{
mLabel = label;
mGroup = group;
mBackground = Color.web(background);
mGradientTop = Color.web(gradientTop);
mGradientBottom = Color.web(gradientBottom);
mTrace = Color.web(trace);
mLine = Color.web(line);
mCursor = Color.web(cursor);
mChannel = Color.web(channel);
mChannelProcessing = Color.web(channelProcessing);
mChannelSelected = Color.web(channelSelected);
mCorrection = Color.web(correction);
mWaterfallPalette = waterfallPalette;
}
/**
* Default theme: the original sdrtrunk spectral display colors.
*/
public static SpectralDisplayTheme getDefault()
{
return CLASSIC;
}
public String getLabel()
{
return mLabel;
}
public Group getGroup()
{
return mGroup;
}
public Color getBackground()
{
return mBackground;
}
/**
* Background color with the opacity, for readout boxes drawn over the display.
*/
public Color getBackground(double opacity)
{
return Color.color(mBackground.getRed(), mBackground.getGreen(), mBackground.getBlue(), opacity);
}
public Color getGradientTop()
{
return mGradientTop;
}
public Color getGradientBottom()
{
return mGradientBottom;
}
public Color getTrace()
{
return mTrace;
}
public Color getLine()
{
return mLine;
}
/**
* Line color with a fraction of its opacity, for grid lines.
*/
public Color getLine(double opacityFraction)
{
return Color.color(mLine.getRed(), mLine.getGreen(), mLine.getBlue(), mLine.getOpacity() * opacityFraction);
}
public Color getCursor()
{
return mCursor;
}
public Color getChannel()
{
return mChannel;
}
public Color getChannelProcessing()
{
return mChannelProcessing;
}
public Color getChannelSelected()
{
return mChannelSelected;
}
public Color getCorrection()
{
return mCorrection;
}
/**
* ARGB color for the waterfall intensity index (0 - 255).
*/
public int getWaterfallColor(int index)
{
return mWaterfallPalette[index & 0xFF];
}
/**
* Waterfall color at the lowest intensity, which is the waterfall background.
*/
public Color getWaterfallBackground()
{
return WaterfallPalette.toColor(mWaterfallPalette[0]);
}
@Override
public String toString()
{
return mLabel;
}
}

View File

@@ -0,0 +1,66 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import io.github.dsheirer.util.ThreadPool;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.scene.Scene;
import javafx.stage.Stage;
/**
* Separate window with a tuner spectral display. The window starts with the saved spectral display settings, but
* changes made in the window are not saved.
*/
public class SpectralDisplayWindow
{
private SpectralDisplayWindow()
{
}
/**
* Opens a window that shows the tuner. Can be called from any thread once the JavaFX toolkit is running.
*/
public static void show(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, Tuner tuner)
{
Platform.runLater(() -> {
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, false);
Scene scene = new Scene(display, 1280, 600);
ThemeManager.getInstance().register(scene);
Stage stage = new Stage();
stage.setScene(scene);
String application = SystemProperties.getInstance().getApplicationName();
stage.titleProperty().bind(Bindings.createStringBinding(() -> {
Tuner displayed = display.displayedTunerProperty().get();
return application + " - " + (displayed != null ? displayed.getPreferredName() : "Spectral Display");
}, display.displayedTunerProperty()));
stage.setOnHidden(e -> ThreadPool.CACHED.submit(display::dispose));
stage.show();
ThreadPool.CACHED.submit(() -> display.showTuner(tuner));
});
}
}

View File

@@ -0,0 +1,176 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
/**
* Spectrum trace averaging and peak hold. Thread safe.
*
* Averaging is an exponential moving average of each bin's linear power with separate rates for rising and falling
* values. A falling bin moves 1 / averaging of the way to the new value each frame, and a rising bin moves
* ATTACK_RATIO times faster, so signals appear quickly while the noise floor stays steady. Averaging linear power
* instead of decibels keeps the result at the true mean power. Averaging decibel values reads about 2.5 dB low for
* noise.
*
* Peak hold keeps the highest recent value of each bin from the unaveraged frames, falling PEAK_DECAY_DB per frame.
*/
public class SpectrumAverager
{
public static final int AVERAGING_MINIMUM = 1;
public static final int AVERAGING_MAXIMUM = 20;
public static final int AVERAGING_DEFAULT = 4;
public static final double ATTACK_RATIO = 2.5;
public static final float PEAK_DECAY_DB = 0.15f;
private static final float MINIMUM_DB = -196.0f;
private static final double LN10_OVER_10 = Math.log(10.0) / 10.0;
private int mAveraging = AVERAGING_DEFAULT;
private boolean mPeakHold = false;
private boolean mResetPeak = false;
//Double precision so the average can fall more than 70 dB in one frame without float rounding error
private double[] mAveragedPower;
private float[] mPeakBins;
/**
* Processed spectrum for one DFT frame.
* @param average bins in dB after averaging
* @param peak hold bins in dB, or null when peak hold is off
*/
public record Frame(float[] average, float[] peak)
{
}
/**
* Sets the averaging, from AVERAGING_MINIMUM (no averaging) to AVERAGING_MAXIMUM (slowest).
*/
public synchronized void setAveraging(int averaging)
{
mAveraging = Math.max(AVERAGING_MINIMUM, Math.min(AVERAGING_MAXIMUM, averaging));
}
/**
* Turns peak hold on or off. Turning it on starts from the next frame.
*/
public synchronized void setPeakHold(boolean peakHold)
{
mPeakHold = peakHold;
mResetPeak = true;
}
/**
* Restarts the peak hold from the next frame, for example after a frequency change.
*/
public synchronized void resetPeak()
{
mResetPeak = true;
}
/**
* Clears the averaging history and the peak hold.
*/
public synchronized void reset()
{
mAveragedPower = null;
mPeakBins = null;
}
/**
* Processes one frame of DFT bins in dB.
*/
public synchronized Frame process(float[] bins)
{
int size = bins.length;
double decay = 1.0 / mAveraging;
double attack = Math.min(1.0, ATTACK_RATIO * decay);
if(mAveragedPower == null || mAveragedPower.length != size)
{
mAveragedPower = new double[size];
for(int x = 0; x < size; x++)
{
mAveragedPower[x] = toPowerDouble(bins[x]);
}
}
else
{
for(int x = 0; x < size; x++)
{
double power = toPowerDouble(bins[x]);
double average = mAveragedPower[x];
mAveragedPower[x] = average + ((power - average) * (power > average ? attack : decay));
}
}
float[] averaged = new float[size];
for(int x = 0; x < size; x++)
{
averaged[x] = mAveragedPower[x] > 0 ?
Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(mAveragedPower[x]))) : MINIMUM_DB;
}
float[] peak = null;
if(mPeakHold)
{
if(mPeakBins == null || mPeakBins.length != size || mResetPeak)
{
mResetPeak = false;
mPeakBins = bins.clone();
}
else
{
for(int x = 0; x < size; x++)
{
mPeakBins[x] = Math.max(bins[x], mPeakBins[x] - PEAK_DECAY_DB);
}
}
peak = mPeakBins.clone();
}
else
{
mPeakBins = null;
}
return new Frame(averaged, peak);
}
/**
* Converts decibels to linear power.
*/
public static float toPower(float decibels)
{
return (float)Math.exp(decibels * LN10_OVER_10);
}
private static double toPowerDouble(float decibels)
{
return Math.exp(decibels * LN10_OVER_10);
}
/**
* Converts linear power to decibels.
*/
public static float toDecibels(float power)
{
return power > 0 ? Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(power))) : MINIMUM_DB;
}
}

View File

@@ -0,0 +1,354 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.source.ISourceEventProcessor;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
import io.github.dsheirer.util.ThreadPool;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Spectral display for a tuner: the spectral display with the decibel, frequency and playlist channel layers, the DFT
* processing for the tuner's sample stream, and the collapsible configuration panel with a tuner menu.
*
* Construct on the JavaFX application thread. showTuner() and clearTuner() can be called from any thread, but they
* can block while the tuner starts or stops its sample stream, so don't call them on the JavaFX application thread.
*/
public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceEventProcessor
{
private static final Logger LOGGER = LoggerFactory.getLogger(TunerSpectralDisplay.class);
/**
* Indicates if the main window spectral display shows a tuner. Cleared when the user turns the display off.
*/
public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled";
public static final String SETTINGS_PREFIX = "spectral.display";
private static final String CHANNEL_DISPLAY = "channel.display";
private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details";
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
private final ChannelLayer mChannelLayer;
private final DiscoveredTunerModel mDiscoveredTunerModel;
private final Object mTunerLock = new Object();
private final ReadOnlyObjectWrapper<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
private Consumer<Tuner> mTunerSelectionHandler;
private Tuner mTuner;
private boolean mProcessing;
/**
* Constructs an instance.
* @param playlistManager for the channels
* @param discoveredTunerModel for the list of tuners
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
*/
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
boolean saveSettings)
{
this(playlistManager != null ? playlistManager.getChannelModel() : null,
playlistManager != null ? playlistManager.getChannelProcessingManager() : null,
discoveredTunerModel, saveSettings);
}
/**
* Constructs an instance.
* @param channelModel for the channels, or null
* @param channelProcessingManager for channel processing events, or null
* @param discoveredTunerModel for the list of tuners, or null
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
*/
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
{
this(new ChannelLayer(channelModel, channelProcessingManager), discoveredTunerModel, saveSettings);
}
private TunerSpectralDisplay(ChannelLayer channelLayer, DiscoveredTunerModel discoveredTunerModel,
boolean saveSettings)
{
super(SETTINGS_PREFIX, saveSettings, channelLayer);
mChannelLayer = channelLayer;
mDiscoveredTunerModel = discoveredTunerModel;
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
SystemProperties properties = SystemProperties.getInstance();
mChannelLayer.channelDisplayProperty().set(parse(ChannelLayer.ChannelDisplay.class,
properties.get(key(CHANNEL_DISPLAY), ChannelLayer.ChannelDisplay.ALL.name()),
ChannelLayer.ChannelDisplay.ALL));
mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true));
mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name()));
mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv));
installConfigPanel();
}
/**
* Tuner shown in the display, updated on the JavaFX application thread.
*/
public ReadOnlyObjectProperty<Tuner> displayedTunerProperty()
{
return mDisplayedTuner.getReadOnlyProperty();
}
/**
* Tuners that can be shown.
*/
public List<Tuner> getAvailableTuners()
{
List<Tuner> tuners = new ArrayList<>();
if(mDiscoveredTunerModel != null)
{
for(DiscoveredTuner discoveredTuner: mDiscoveredTunerModel.getAvailableTuners())
{
if(discoveredTuner.hasTuner())
{
tuners.add(discoveredTuner.getTuner());
}
}
}
return tuners;
}
/**
* Sets the handler for tuner selections in the configuration panel. The default handler shows the selected tuner.
* @param handler receives the selected tuner, or null when the user selects no tuner. Called on the JavaFX thread.
*/
public void setTunerSelectionHandler(Consumer<Tuner> handler)
{
mTunerSelectionHandler = handler;
}
/**
* Called by the configuration panel on the JavaFX application thread when the user selects a tuner, or null.
*/
void tunerSelected(Tuner tuner)
{
if(mTunerSelectionHandler != null)
{
mTunerSelectionHandler.accept(tuner);
}
else
{
ThreadPool.CACHED.submit(() -> {
if(tuner != null)
{
showTuner(tuner);
}
else
{
clearTuner();
}
});
}
}
/**
* Currently displayed tuner, or null.
*/
public Tuner getTuner()
{
synchronized(mTunerLock)
{
return mTuner;
}
}
/**
* Shows the tuner in the display and stops showing the previous tuner.
*/
public void showTuner(Tuner tuner)
{
synchronized(mTunerLock)
{
if(tuner == mTuner)
{
return;
}
clearTuner();
if(tuner == null)
{
return;
}
mTuner = tuner;
tuner.getTunerController().addListener(this);
long frequency = tuner.getTunerController().getFrequency();
int sampleRate = (int)tuner.getTunerController().getSampleRate();
double sampleSize = tuner.getSampleSize();
runOnFxThread(() -> {
if(getTuner() == tuner)
{
SpectralDisplayModel model = getSpectralDisplay().getModel();
model.sampleSizeProperty().set(sampleSize);
model.centerFrequencyProperty().set(frequency);
model.sampleRateProperty().set(sampleRate);
getSpectralDisplay().messageProperty().set(null);
mDisplayedTuner.set(tuner);
}
});
updateProcessing();
}
}
/**
* Stops showing the current tuner.
*/
public void clearTuner()
{
synchronized(mTunerLock)
{
Tuner tuner = mTuner;
if(tuner != null)
{
stopProcessing();
tuner.getTunerController().removeListener(this);
mTuner = null;
}
getSpectralDisplay().clear();
runOnFxThread(() -> {
if(getTuner() == null)
{
SpectralDisplayModel model = getSpectralDisplay().getModel();
model.sampleRateProperty().set(0);
model.centerFrequencyProperty().set(0);
model.resetZoom();
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
mDisplayedTuner.set(null);
}
});
}
}
@Override
protected void processingEnabledChanged()
{
ThreadPool.CACHED.submit(() -> {
synchronized(mTunerLock)
{
updateProcessing();
}
});
}
/**
* Starts or stops processing the tuner sample stream. Hold the tuner lock when calling.
*/
private void updateProcessing()
{
if(mTuner != null && isProcessingEnabled())
{
startProcessing();
}
else
{
stopProcessing();
}
}
private void startProcessing()
{
if(!mProcessing && mTuner != null)
{
mProcessing = true;
startDft();
try
{
mTuner.getTunerController().addBufferListener(getDftProcessor());
}
catch(Exception e)
{
LOGGER.error("Error registering spectral display for tuner sample buffers", e);
}
}
}
private void stopProcessing()
{
if(mProcessing)
{
mProcessing = false;
if(mTuner != null)
{
mTuner.getTunerController().removeBufferListener(getDftProcessor());
}
stopDft();
}
}
/**
* Receives frequency and sample rate changes from the tuner.
*/
@Override
public void process(SourceEvent event)
{
switch(event.getEvent())
{
case NOTIFICATION_FREQUENCY_CHANGE ->
{
long frequency = event.getValue().longValue();
runOnFxThread(() -> getSpectralDisplay().getModel().centerFrequencyProperty().set(frequency));
}
case NOTIFICATION_SAMPLE_RATE_CHANGE ->
{
int sampleRate = event.getValue().intValue();
runOnFxThread(() -> getSpectralDisplay().getModel().sampleRateProperty().set(sampleRate));
}
default ->
{
}
}
}
/**
* Stops showing the tuner and releases resources. Call from a thread other than the JavaFX application thread
* when a tuner is showing, since stopping the sample stream can block.
*/
@Override
public void dispose()
{
clearTuner();
super.dispose();
}
}

View File

@@ -0,0 +1,126 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import javafx.scene.paint.Color;
/**
* Builds waterfall color palettes that map an intensity index (0 - 255) to an opaque ARGB color.
*/
public class WaterfallPalette
{
public static final int SIZE = 256;
private WaterfallPalette()
{
}
/**
* Palette of the original Swing waterfall: dark blue noise floor, a blue to yellow ramp, yellow fading to orange,
* then red.
*/
public static int[] classic()
{
int[] palette = new int[SIZE];
for(int x = 0; x < 16; x++)
{
palette[x] = argb(0, 0, 127);
}
for(int x = 16; x < 32; x++)
{
palette[x] = argb(0, 0, 191);
}
int r = 0;
int g = 0;
int b = 191;
for(int x = 32; x < 60; x++)
{
palette[x] = argb(r, g, b);
r += 9;
g += 9;
b -= 6;
}
g = 255;
for(int x = 60; x < 188; x++)
{
palette[x] = argb(255, g, 0);
g -= 2;
}
for(int x = 188; x < SIZE; x++)
{
palette[x] = argb(255, 0, 0);
}
return palette;
}
/**
* Palette that blends linearly between evenly spaced colors.
* @param colors web color strings (for example #440154), lowest intensity first
*/
public static int[] gradient(String... colors)
{
if(colors.length < 2)
{
throw new IllegalArgumentException("A gradient palette needs at least two colors");
}
Color[] stops = new Color[colors.length];
for(int x = 0; x < colors.length; x++)
{
stops[x] = Color.web(colors[x]);
}
int[] palette = new int[SIZE];
int segments = stops.length - 1;
for(int x = 0; x < SIZE; x++)
{
double position = (double)x / (SIZE - 1) * segments;
int segment = Math.min(segments - 1, (int)Math.floor(position));
Color color = stops[segment].interpolate(stops[segment + 1], position - segment);
palette[x] = argb((int)Math.round(color.getRed() * 255), (int)Math.round(color.getGreen() * 255),
(int)Math.round(color.getBlue() * 255));
}
return palette;
}
/**
* JavaFX color for an ARGB palette entry.
*/
public static Color toColor(int argb)
{
return Color.rgb((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
}
private static int argb(int r, int g, int b)
{
return 0xFF000000 | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF);
}
}

View File

@@ -0,0 +1,744 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
import io.github.dsheirer.gui.spectrum.DisplayCursor;
import io.github.dsheirer.gui.spectrum.DisplaySection;
import io.github.dsheirer.gui.spectrum.RenderArea;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.ComboBox;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
/**
* Playlist channel layer. Draws each playlist channel, enabled (processing) or disabled, and each active traffic
* channel (tracked from the channel processing start and stop events) as a translucent box at its frequency and bandwidth, labeled with the system, site, channel name and
* decoder. Processing and selected channels use their own colors. When the decoder applies a frequency correction,
* a bracket at the bottom of the box shows the corrected channel position and the PPM error.
*
* When the zoom level is too low for a channel's labels to fit in its box, moving the mouse over the channel shows a
* popout card near the bottom of the spectrum (or the waterfall) with the channel name, system, site, decoder,
* frequency and state. The popout can be turned off with the hover details setting.
*
* Right-clicking a channel adds a menu item to view or edit the channel in the playlist editor.
*/
public class ChannelLayer extends SpectralDisplayLayer
{
private static final Font FONT = Font.font(11);
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.0");
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final Font POPOUT_TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
private static final double MIN_LABEL_BOX_WIDTH = 12;
private static final double POPOUT_MIN_HIT_WIDTH = 10;
private static final double POPOUT_PADDING = 6;
private static final double POPOUT_GAP = 5;
private static final double POPOUT_SWATCH = 8;
private static final int POPOUT_MAX_CHANNELS = 4;
/**
* Which channels the layer shows.
*/
public enum ChannelDisplay
{
ALL("All Channels"),
ENABLED("Enabled Channels"),
NONE("No Channels");
private final String mLabel;
ChannelDisplay(String label)
{
mLabel = label;
}
@Override
public String toString()
{
return mLabel;
}
}
private final ChannelModel mChannelModel;
private final ChannelProcessingManager mChannelProcessingManager;
private final Listener<ChannelEvent> mChannelEventListener = this::receive;
//Active traffic channels. Traffic channel managers keep their temporary channels out of the channel model, so
//they are tracked from the processing start and stop events.
private final Set<Channel> mTrafficChannels = ConcurrentHashMap.newKeySet();
private final ObjectProperty<ChannelDisplay> mChannelDisplay = new SimpleObjectProperty<>(ChannelDisplay.ALL);
private final BooleanProperty mHoverDetails = new SimpleBooleanProperty(true);
private final Text mMeasure = new Text();
private final double mTextHeight;
private volatile boolean mChannelsChanged = true;
private List<Channel> mChannels = new ArrayList<>();
private long mQueryMinimum;
private long mQueryMaximum;
/**
* Constructs an instance.
* @param channelModel for the playlist channels and the active traffic channels
* @param channelProcessingManager for channel processing start and stop events
*/
public ChannelLayer(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager)
{
mChannelModel = channelModel;
mChannelProcessingManager = channelProcessingManager;
mMeasure.setFont(FONT);
mMeasure.setText("Ag");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
mChannelDisplay.addListener((ob, ov, nv) -> requestRender());
mHoverDetails.addListener((ob, ov, nv) -> requestRender());
if(mChannelModel != null)
{
mChannelModel.addListener(mChannelEventListener);
}
if(mChannelProcessingManager != null)
{
mChannelProcessingManager.addChannelEventListener(mChannelEventListener);
//Traffic channels that were already running before this layer was created
for(Channel channel: mChannelProcessingManager.getProcessingChannels())
{
if(channel.isTrafficChannel())
{
mTrafficChannels.add(channel);
}
}
}
}
/**
* Tracks active traffic channels and refreshes the channels on any channel change.
*/
private void receive(ChannelEvent event)
{
Channel channel = event.getChannel();
if(channel != null && channel.isTrafficChannel())
{
switch(event.getEvent())
{
case NOTIFICATION_PROCESSING_START -> mTrafficChannels.add(channel);
case NOTIFICATION_PROCESSING_STOP, NOTIFICATION_PROCESSING_START_REJECTED, NOTIFICATION_DELETE ->
mTrafficChannels.remove(channel);
default ->
{
}
}
}
channelsChanged();
}
/**
* Which channels to show.
*/
public ObjectProperty<ChannelDisplay> channelDisplayProperty()
{
return mChannelDisplay;
}
@Override
public void dispose()
{
if(mChannelModel != null)
{
mChannelModel.removeListener(mChannelEventListener);
}
if(mChannelProcessingManager != null)
{
mChannelProcessingManager.removeChannelEventListener(mChannelEventListener);
}
mChannels.clear();
mTrafficChannels.clear();
}
private void channelsChanged()
{
mChannelsChanged = true;
requestRender();
}
@Override
public String getConfigurationTitle()
{
return "Channels";
}
@Override
public List<ConfigurationRow> getConfigurationRows()
{
ComboBox<ChannelDisplay> comboBox = new ComboBox<>();
comboBox.getItems().addAll(ChannelDisplay.values());
comboBox.valueProperty().bindBidirectional(mChannelDisplay);
comboBox.setMaxWidth(Double.MAX_VALUE);
ConfigurationRow details = ConfigurationRow.toggle("Hover Details", mHoverDetails);
Tooltip.install(details.control(), new Tooltip("Show channel details under the mouse when the channel " +
"labels don't fit at the current zoom"));
return List.of(new ConfigurationRow("Show", comboBox), details);
}
/**
* Shows a popout card with the details of the channels under the mouse when their labels don't fit.
*/
public BooleanProperty hoverDetailsProperty()
{
return mHoverDetails;
}
/**
* Channels that overlap the source bandwidth, refreshed when the channels or the source frequency change.
*/
private List<Channel> getChannels(SpectralDisplayModel model)
{
long minimum = model.getMinFrequency();
long maximum = model.getMaxFrequency();
if(mChannelsChanged || minimum != mQueryMinimum || maximum != mQueryMaximum)
{
try
{
mChannelsChanged = false;
List<Channel> channels = mChannelModel != null ?
mChannelModel.getChannelsInFrequencyRange(minimum, maximum) : new ArrayList<>();
for(Channel trafficChannel: mTrafficChannels)
{
if(!channels.contains(trafficChannel) && trafficChannel.isWithin(minimum, maximum))
{
channels.add(trafficChannel);
}
}
mChannels = channels;
mQueryMinimum = minimum;
mQueryMaximum = maximum;
}
catch(ConcurrentModificationException cme)
{
//The channel lists changed while reading them - try again on the next frame
mChannelsChanged = true;
requestRender();
}
}
return mChannels;
}
private boolean isShown(Channel channel)
{
return switch(mChannelDisplay.get())
{
case ALL -> true;
case ENABLED -> channel.isProcessing();
default -> false;
};
}
@Override
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
{
return;
}
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
for(Channel channel: getChannels(model))
{
if(!isShown(channel))
{
continue;
}
Color color = channel.isSelected() ? theme.getChannelSelected() :
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
gc.setFill(color);
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
Box box = getBox(model, tunerChannel, area);
if(box != null)
{
gc.fillRect(box.x(), 0, box.width(), area.plotHeight());
}
}
}
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
{
return;
}
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
Color line = theme.getLine();
gc.setFont(FONT);
gc.setTextBaseline(VPos.TOP);
gc.setLineWidth(1.0);
for(Channel channel: getChannels(model))
{
if(!isShown(channel))
{
continue;
}
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
Box box = getBox(model, tunerChannel, area);
if(box == null)
{
continue;
}
double center = Math.floor(box.center()) + 0.5;
gc.setStroke(line);
gc.strokeLine(center, area.plotHeight() * 0.65, center, area.plotHeight() - 1);
if(box.width() >= MIN_LABEL_BOX_WIDTH)
{
gc.save();
gc.beginPath();
gc.rect(box.x(), 0, box.width(), area.plotHeight());
gc.clip();
gc.setFill(line);
double y = 1;
y = drawLabel(gc, channel.hasSystem() ? channel.getSystem() : null, box, y);
y = drawLabel(gc, channel.hasSite() ? channel.getSite() : null, box, y);
y = drawLabel(gc, channel.getName(), box, y);
drawLabel(gc, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), box, y);
gc.restore();
}
int correction = channel.getChannelFrequencyCorrection();
if(correction != 0)
{
drawCorrection(gc, model, area, tunerChannel, box, correction);
}
}
}
renderPopout(gc, area, DisplaySection.SPECTRUM);
}
@Override
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
renderPopout(gc, area, DisplaySection.WATERFALL);
}
/**
* Draws a card near the bottom of the section under the mouse with the details of each channel under the mouse
* whose labels don't fit in its box at the current zoom. The card moves to the top of the section when the mouse
* is over the bottom of the section.
*/
private void renderPopout(GraphicsContext gc, RenderArea area, DisplaySection section)
{
SpectralDisplayModel model = getModel();
DisplayCursor cursor = getSpectralDisplay().getDisplayCursor();
if(!mHoverDetails.get() || model == null || !model.hasSource() || cursor == null ||
cursor.section() != section || mChannelDisplay.get() == ChannelDisplay.NONE)
{
return;
}
List<PopoutEntry> entries = getPopoutEntries(model, area, cursor.x());
if(entries.isEmpty())
{
return;
}
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
int shown = Math.min(entries.size(), POPOUT_MAX_CHANNELS);
String more = entries.size() > shown ? "+ " + (entries.size() - shown) + " more" : null;
//Measure the card
double textWidth = 0;
int lineCount = 0;
for(int x = 0; x < shown; x++)
{
PopoutEntry entry = entries.get(x);
textWidth = Math.max(textWidth, measure(entry.title(), POPOUT_TITLE_FONT) + POPOUT_SWATCH + 5);
for(String line: entry.details())
{
textWidth = Math.max(textWidth, measure(line, FONT));
}
lineCount += 1 + entry.details().size();
}
if(more != null)
{
textWidth = Math.max(textWidth, measure(more, FONT));
lineCount++;
}
double width = textWidth + (POPOUT_PADDING * 2);
double height = (lineCount * mTextHeight) + ((shown - 1) * POPOUT_GAP) + (POPOUT_PADDING * 2);
double bottom = section == DisplaySection.SPECTRUM ? area.plotHeight() : area.height();
double x = Math.max(4, Math.min(area.width() - width - 4, cursor.x() - (width / 2.0)));
double y = bottom - height - 6;
//Keep the card out from under the mouse
if(cursor.y() >= y - 4)
{
y = 6;
}
if(y < 0 || width > area.width())
{
return;
}
gc.setFill(theme.getBackground(0.88));
gc.fillRoundRect(x, y, width, height, 8, 8);
gc.setStroke(opaque(theme.getLine()));
gc.setLineWidth(1.0);
gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.TOP);
double lineY = y + POPOUT_PADDING;
double textX = x + POPOUT_PADDING;
for(int index = 0; index < shown; index++)
{
PopoutEntry entry = entries.get(index);
double swatchY = lineY + ((mTextHeight - POPOUT_SWATCH) / 2.0);
gc.setFill(opaque(entry.color()));
gc.fillRect(textX, swatchY, POPOUT_SWATCH, POPOUT_SWATCH);
gc.setFill(theme.getCursor());
gc.setFont(POPOUT_TITLE_FONT);
gc.fillText(entry.title(), textX + POPOUT_SWATCH + 5, lineY);
lineY += mTextHeight;
gc.setFill(opaque(theme.getLine()));
gc.setFont(FONT);
for(String line: entry.details())
{
gc.fillText(line, textX, lineY);
lineY += mTextHeight;
}
lineY += POPOUT_GAP;
}
if(more != null)
{
gc.fillText(more, textX, lineY - POPOUT_GAP);
}
}
/**
* Channels under the screen x position whose labels don't fit in the channel box. The hit area of a narrow
* channel is widened so it can be found with the mouse.
*/
private List<PopoutEntry> getPopoutEntries(SpectralDisplayModel model, RenderArea area, double cursorX)
{
List<PopoutEntry> entries = new ArrayList<>();
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
for(Channel channel: getChannels(model))
{
if(!isShown(channel))
{
continue;
}
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
Box box = getBox(model, tunerChannel, area);
if(box == null)
{
continue;
}
double hitWidth = Math.max(POPOUT_MIN_HIT_WIDTH, box.width());
double hitStart = box.center() - (hitWidth / 2.0);
if(cursorX < hitStart || cursorX > hitStart + hitWidth || labelsFit(channel, box))
{
continue;
}
List<String> details = new ArrayList<>();
String systemSite = join(channel.hasSystem() ? channel.getSystem() : null,
channel.hasSite() ? channel.getSite() : null);
if(systemSite != null)
{
details.add(systemSite);
}
String state = channel.isProcessing() ? "Enabled" : "Disabled";
if(channel.isSelected())
{
state += ", Selected";
}
details.add(join(channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(),
FREQUENCY_FORMAT.format(tunerChannel.getFrequency() / 1E6) + " MHz", state));
Color color = channel.isSelected() ? theme.getChannelSelected() :
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
entries.add(new PopoutEntry(channel.getName() != null ? channel.getName() : "(unnamed)", details,
color));
break;
}
}
return entries;
}
/**
* Indicates if all of the channel labels fit inside the channel box without clipping.
*/
private boolean labelsFit(Channel channel, Box box)
{
if(box.width() < MIN_LABEL_BOX_WIDTH)
{
return false;
}
String[] labels = {channel.hasSystem() ? channel.getSystem() : null,
channel.hasSite() ? channel.getSite() : null, channel.getName(),
channel.getDecodeConfiguration().getDecoderType().getShortDisplayString()};
for(String label: labels)
{
if(label != null && !label.isEmpty() && measure(label, FONT) > box.width())
{
return false;
}
}
return true;
}
private double measure(String text, Font font)
{
mMeasure.setFont(font);
mMeasure.setText(text);
double width = mMeasure.getLayoutBounds().getWidth();
mMeasure.setFont(FONT);
return width;
}
/**
* Joins the non-empty values with a separator, or returns null when there are none.
*/
private static String join(String... values)
{
StringBuilder sb = new StringBuilder();
for(String value: values)
{
if(value != null && !value.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(" \u00B7 ");
}
sb.append(value);
}
}
return sb.isEmpty() ? null : sb.toString();
}
private static Color opaque(Color color)
{
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
/**
* Draws the label centered in the box, or left aligned and clipped when it is wider than the box.
* @return y position for the next label
*/
private double drawLabel(GraphicsContext gc, String text, Box box, double y)
{
if(text != null && !text.isEmpty())
{
mMeasure.setText(text);
double width = mMeasure.getLayoutBounds().getWidth();
if(width > box.width())
{
gc.setTextAlign(TextAlignment.LEFT);
gc.fillText(text, box.x() + 1, y);
}
else
{
gc.setTextAlign(TextAlignment.CENTER);
gc.fillText(text, box.center(), y);
}
}
return y + mTextHeight;
}
/**
* Draws a bracket at the bottom of the channel at the corrected frequency, labeled with the PPM error.
*/
private void drawCorrection(GraphicsContext gc, SpectralDisplayModel model, RenderArea area,
TunerChannel tunerChannel, Box box, int correction)
{
double top = area.plotHeight() * 0.88;
double bottom = area.plotHeight() * 0.98;
double corrected = model.getX(tunerChannel.getFrequency() + correction, area.width());
double start = corrected - (box.width() / 2.0);
double stop = corrected + (box.width() / 2.0);
Color correctionColor = getSpectralDisplay().getTheme().getCorrection();
gc.setStroke(correctionColor);
gc.strokeLine(start, bottom, stop, bottom);
gc.strokeLine(start, top, start, bottom);
gc.strokeLine(stop, top, stop, bottom);
double ppm = correction / (tunerChannel.getFrequency() / 1E6);
String label = "PPM " + PPM_FORMAT.format(ppm);
mMeasure.setText(label);
if(mMeasure.getLayoutBounds().getWidth() <= box.width() && mTextHeight * 5 <= area.plotHeight())
{
gc.setFill(correctionColor);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.BOTTOM);
gc.fillText(label, start + 1, bottom - 2);
gc.setTextBaseline(VPos.TOP);
}
}
/**
* Screen position of the tuner channel, or null when it is outside the display.
*/
private static Box getBox(SpectralDisplayModel model, TunerChannel tunerChannel, RenderArea area)
{
long minimum = (long)Math.floor(model.getDisplayMinFrequency());
long maximum = (long)Math.ceil(model.getDisplayMaxFrequency());
if(tunerChannel == null || !tunerChannel.overlaps(minimum, maximum))
{
return null;
}
double center = model.getX(tunerChannel.getFrequency(), area.width());
double width = tunerChannel.getBandwidth() / model.getDisplayBandwidth() * area.width();
return new Box(center - (width / 2.0), width, center);
}
@Override
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
{
List<MenuItem> items = new ArrayList<>();
SpectralDisplayModel model = getModel();
if(model == null || mChannelDisplay.get() == ChannelDisplay.NONE)
{
return items;
}
for(Channel channel: getChannels(model))
{
if(!isShown(channel))
{
continue;
}
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
frequency <= tunerChannel.getMaxFrequency())
{
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
items.add(item);
break;
}
}
}
return items;
}
/**
* Channel box screen position.
*/
private record Box(double x, double width, double center)
{
}
/**
* Channel details for the popout card.
*/
private record PopoutEntry(String title, List<String> details, Color color)
{
}
}

View File

@@ -0,0 +1,253 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.gui.spectrum.RenderArea;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import java.text.DecimalFormat;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
/**
* Channel tuning layer for a single channel's spectrum, centered on the channel frequency. Shades the channel
* passband and marks its edges with the theme's processing channel color, and draws the estimated carrier position
* from the decoder's frequency correction (PLL) as a line in the theme's correction color, labeled with the offset
* from the channel center. Both markers also show on the waterfall so carrier drift is easy to follow.
*/
public class ChannelTuningLayer extends SpectralDisplayLayer
{
private static final Font FONT = Font.font(11);
private static final DecimalFormat KHZ_FORMAT = new DecimalFormat("0.###");
private static final DecimalFormat OFFSET_FORMAT = new DecimalFormat("+0;-0");
private final IntegerProperty mChannelBandwidth = new SimpleIntegerProperty(0);
private final LongProperty mCarrierOffset = new SimpleLongProperty(0);
private final BooleanProperty mCarrierOffsetValid = new SimpleBooleanProperty(false);
private final Text mMeasure = new Text();
private final double mTextHeight;
/**
* Constructs an instance.
*/
public ChannelTuningLayer()
{
mMeasure.setFont(FONT);
mMeasure.setText("0123456789 kHz");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
mChannelBandwidth.addListener((ob, ov, nv) -> requestRender());
mCarrierOffset.addListener((ob, ov, nv) -> requestRender());
mCarrierOffsetValid.addListener((ob, ov, nv) -> requestRender());
}
/**
* Channel bandwidth in Hertz, or zero for no passband markers. JavaFX thread only.
*/
public IntegerProperty channelBandwidthProperty()
{
return mChannelBandwidth;
}
/**
* Estimated carrier offset from the channel center in Hertz. JavaFX thread only.
*/
public LongProperty carrierOffsetProperty()
{
return mCarrierOffset;
}
/**
* Indicates if the decoder has reported a carrier offset. The carrier marker only shows when true.
*/
public BooleanProperty carrierOffsetValidProperty()
{
return mCarrierOffsetValid;
}
@Override
public String getConfigurationTitle()
{
return null;
}
@Override
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
{
double[] edges = getEdges(area.width());
if(edges != null)
{
gc.setFill(fade(getTheme().getChannelProcessing(), 0.6));
gc.fillRect(edges[0], 0, edges[1] - edges[0], area.plotHeight());
}
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayTheme theme = getTheme();
double[] edges = getEdges(area.width());
gc.setFont(FONT);
gc.setTextBaseline(VPos.TOP);
gc.setLineWidth(1.0);
if(edges != null)
{
Color edge = opaque(theme.getChannelProcessing());
gc.setStroke(edge);
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.plotHeight());
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.plotHeight());
String label = formatBandwidth(mChannelBandwidth.get());
mMeasure.setText(label);
if(mMeasure.getLayoutBounds().getWidth() + 8 < edges[1] - edges[0])
{
gc.setFill(edge);
gc.setTextAlign(TextAlignment.CENTER);
gc.fillText(label, (edges[0] + edges[1]) / 2.0, 2);
}
}
Double carrier = getCarrierX(area.width());
if(carrier != null)
{
Color color = theme.getCorrection();
gc.setStroke(color);
gc.strokeLine(snap(carrier), 0, snap(carrier), area.plotHeight());
String label = "PLL " + OFFSET_FORMAT.format(mCarrierOffset.get()) + " Hz";
mMeasure.setText(label);
double width = mMeasure.getLayoutBounds().getWidth();
double x = carrier + 5;
if(x + width + 4 > area.width())
{
x = carrier - 5 - width;
}
double y = mTextHeight + 6;
gc.setFill(theme.getBackground(0.7));
gc.fillRoundRect(x - 3, y - 1, width + 6, mTextHeight + 2, 4, 4);
gc.setFill(color);
gc.setTextAlign(TextAlignment.LEFT);
gc.fillText(label, x, y);
}
}
@Override
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
SpectralDisplayTheme theme = getTheme();
double[] edges = getEdges(area.width());
gc.setLineWidth(1.0);
if(edges != null)
{
gc.setStroke(fade(opaque(theme.getChannelProcessing()), 0.7));
gc.setLineDashes(4.0, 4.0);
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.height());
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.height());
gc.setLineDashes((double[])null);
}
Double carrier = getCarrierX(area.width());
if(carrier != null)
{
gc.setStroke(fade(theme.getCorrection(), 0.6));
gc.strokeLine(snap(carrier), 0, snap(carrier), area.height());
}
}
/**
* Screen x positions of the lower and upper channel edges, or null when there is no channel bandwidth.
*/
private double[] getEdges(double width)
{
SpectralDisplayModel model = getModel();
int bandwidth = mChannelBandwidth.get();
if(model == null || !model.hasSource() || bandwidth <= 0)
{
return null;
}
long center = model.getCenterFrequency();
return new double[]{model.getX(center - (bandwidth / 2.0), width), model.getX(center + (bandwidth / 2.0),
width)};
}
/**
* Screen x position of the estimated carrier, or null when there is no carrier estimate.
*/
private Double getCarrierX(double width)
{
SpectralDisplayModel model = getModel();
if(model == null || !model.hasSource() || !mCarrierOffsetValid.get())
{
return null;
}
return model.getX(model.getCenterFrequency() + mCarrierOffset.get(), width);
}
private SpectralDisplayTheme getTheme()
{
return getSpectralDisplay().getTheme();
}
/**
* Formats a bandwidth in Hz or kHz.
*/
static String formatBandwidth(int hertz)
{
return hertz >= 1000 ? KHZ_FORMAT.format(hertz / 1000.0) + " kHz" : hertz + " Hz";
}
private static Color opaque(Color color)
{
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
private static Color fade(Color color, double fraction)
{
return Color.color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * fraction);
}
private static double snap(double x)
{
return Math.floor(x) + 0.5;
}
}

View File

@@ -0,0 +1,183 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
import io.github.dsheirer.gui.spectrum.RenderArea;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import java.util.List;
/**
* Decibel scale layer. Draws horizontal grid lines across the spectrum at even dBFS steps, with labels along the
* left edge. The step size (1, 2, 5, 10 or 20 dB) adapts to the spectrum height so the labels don't crowd.
*
* The spectrum plot runs from 0 dBFS at the top to minus the model's dynamic range at the bottom.
*/
public class DecibelLayer extends SpectralDisplayLayer
{
private static final Font FONT = Font.font(11);
private static final double[] STEPS = {1, 2, 5, 10, 20, 50};
private static final double MIN_LINE_SPACING = 28;
private static final double TOP_MARGIN = 22;
private final BooleanProperty mVisible = new SimpleBooleanProperty(true);
private final Text mMeasure = new Text();
private final double mTextHeight;
/**
* Constructs an instance.
*/
public DecibelLayer()
{
mMeasure.setFont(FONT);
mMeasure.setText("-0123456789 dBFS");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
mVisible.addListener((ob, ov, nv) -> requestRender());
}
/**
* Shows or hides the decibel scale.
*/
public BooleanProperty visibleProperty()
{
return mVisible;
}
@Override
public String getConfigurationTitle()
{
//A single switch - list it in the Display section instead of a section of its own
return null;
}
@Override
public List<ConfigurationRow> getConfigurationRows()
{
return List.of(ConfigurationRow.toggle("dB Scale", mVisible));
}
/**
* Grid line spacing in dB so the lines are at least MIN_LINE_SPACING pixels apart.
*/
static double getStep(double range, double plotHeight)
{
if(plotHeight <= 0 || range <= 0)
{
return STEPS[STEPS.length - 1];
}
double pixelsPerDb = plotHeight / range;
for(double step: STEPS)
{
if(step * pixelsPerDb >= MIN_LINE_SPACING)
{
return step;
}
}
return STEPS[STEPS.length - 1];
}
@Override
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(!mVisible.get() || model == null || !model.hasSource())
{
return;
}
double range = model.getDynamicRange();
double step = getStep(range, area.plotHeight());
gc.setStroke(getSpectralDisplay().getTheme().getLine(0.35));
gc.setLineWidth(1.0);
gc.setLineDashes(2.0, 4.0);
for(double decibels = -step; decibels > -range; decibels -= step)
{
double y = Math.floor(getY(decibels, range, area.plotHeight())) + 0.5;
gc.strokeLine(0, y, area.width(), y);
}
gc.setLineDashes((double[])null);
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(!mVisible.get() || model == null || !model.hasSource())
{
return;
}
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
double range = model.getDynamicRange();
double plotHeight = area.plotHeight();
double step = getStep(range, plotHeight);
boolean unitShown = false;
gc.setFont(FONT);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.CENTER);
for(double decibels = -step; decibels > -range; decibels -= step)
{
double y = getY(decibels, range, plotHeight);
//Leave room for the zoom indicator at the top and the frequency labels at the bottom
if(y < TOP_MARGIN || y > plotHeight - (mTextHeight / 2.0))
{
continue;
}
String label = format(decibels) + (unitShown ? "" : " dBFS");
unitShown = true;
mMeasure.setText(label);
double width = mMeasure.getLayoutBounds().getWidth();
gc.setFill(theme.getBackground(0.6));
gc.fillRoundRect(2, y - (mTextHeight / 2.0), width + 6, mTextHeight, 4, 4);
gc.setFill(theme.getLine());
gc.fillText(label, 5, y);
}
}
private static double getY(double decibels, double range, double plotHeight)
{
return -decibels / range * plotHeight;
}
private static String format(double decibels)
{
return decibels == Math.rint(decibels) ? String.valueOf((long)decibels) : String.valueOf(decibels);
}
}

View File

@@ -0,0 +1,313 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.gui.spectrum.DisplayCursor;
import io.github.dsheirer.gui.spectrum.DisplaySection;
import io.github.dsheirer.gui.spectrum.RenderArea;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import java.text.DecimalFormat;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
/**
* Frequency axis layer. Draws frequency labels and tick marks along the bottom of the spectrum, faint vertical grid
* lines at each label, and a cursor line with a frequency readout that follows the mouse across the spectrum and the
* waterfall. Over the spectrum, the readout also shows the level under the mouse in dB.
*/
public class FrequencyLayer extends SpectralDisplayLayer
{
private static final Font FONT = Font.font(11);
private static final double INSET_PADDING = 8.0;
private static final double MAJOR_TICK = 6.0;
private static final double MINOR_TICK = 3.0;
private static final DecimalFormat CURSOR_FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat CURSOR_LEVEL_FORMAT = new DecimalFormat("0.0");
private final Text mMeasure = new Text();
private final double mTextHeight;
private final DecimalFormat mLabelFormat = new DecimalFormat("0.0");
/**
* Constructs an instance.
*/
public FrequencyLayer()
{
mMeasure.setFont(FONT);
mMeasure.setText("0123456789.");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
}
@Override
public String getConfigurationTitle()
{
return "Frequency";
}
@Override
public double getSpectrumBottomInset()
{
return mTextHeight + INSET_PADDING;
}
@Override
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(model == null || !model.hasSource())
{
return;
}
Scale scale = getScale(model, area.width());
gc.setStroke(getTheme().getLine(0.35));
gc.setLineWidth(1.0);
gc.setLineDashes(2.0, 4.0);
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep());
frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep())
{
double x = snap(model.getX(frequency, area.width()));
gc.strokeLine(x, 0, x, area.plotHeight());
}
gc.setLineDashes((double[])null);
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
SpectralDisplayModel model = getModel();
if(model != null && model.hasSource())
{
renderAxis(gc, area, model);
}
renderCursor(gc, area, DisplaySection.SPECTRUM);
}
@Override
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
renderCursor(gc, area, DisplaySection.WATERFALL);
}
/**
* Draws the tick marks and frequency labels in the inset below the spectrum plot.
*/
private void renderAxis(GraphicsContext gc, RenderArea area, SpectralDisplayModel model)
{
double width = area.width();
double base = Math.floor(area.plotHeight()) + 0.5;
Scale scale = getScale(model, width);
Color line = getTheme().getLine();
gc.setStroke(line);
gc.setLineWidth(1.0);
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.minorStep());
frequency <= model.getDisplayMaxFrequency(); frequency += scale.minorStep())
{
double x = snap(model.getX(frequency, width));
double length = (frequency % scale.labelStep() == 0) ? MAJOR_TICK : MINOR_TICK;
gc.strokeLine(x, base, x, base + length);
}
gc.setFill(line);
gc.setFont(FONT);
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.BOTTOM);
setPrecision(scale.decimals());
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep());
frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep())
{
double x = model.getX(frequency, width);
String label = mLabelFormat.format(frequency / 1E6);
double half = measure(label) / 2.0;
//Skip labels that would be cut off at the edges
if(x - half >= 0 && x + half <= width)
{
gc.fillText(label, x, area.height() - 1);
}
}
}
/**
* Draws the cursor line in both sections and the readout in the section under the mouse.
*/
private void renderCursor(GraphicsContext gc, RenderArea area, DisplaySection section)
{
DisplayCursor cursor = getSpectralDisplay().getDisplayCursor();
SpectralDisplayModel model = getModel();
if(cursor == null || model == null || !model.hasSource())
{
return;
}
Color color = getTheme().getCursor();
double x = snap(cursor.x());
gc.setStroke(color);
gc.setLineWidth(1.0);
gc.strokeLine(x, 0, x, area.plotHeight());
if(cursor.section() != section)
{
return;
}
String frequency = CURSOR_FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz";
String level = null;
if(section == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
{
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
level = CURSOR_LEVEL_FORMAT.format(decibels) + " dBFS";
}
double textWidth = measure(frequency);
if(level != null)
{
textWidth = Math.max(textWidth, measure(level));
}
double lines = level != null ? 2 : 1;
double boxWidth = textWidth + 8;
double boxHeight = (mTextHeight * lines) + 4;
double boxX = cursor.x() + 8;
if(boxX + boxWidth > area.width())
{
boxX = cursor.x() - 8 - boxWidth;
}
double boxY = Math.max(0, Math.min(area.plotHeight() - boxHeight, cursor.y() - boxHeight - 4));
gc.setFill(getTheme().getBackground(0.7));
gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6);
gc.setFill(color);
gc.setFont(FONT);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.TOP);
gc.fillText(frequency, boxX + 4, boxY + 2);
if(level != null)
{
gc.fillText(level, boxX + 4, boxY + 2 + mTextHeight);
}
}
private SpectralDisplayTheme getTheme()
{
return getSpectralDisplay().getTheme();
}
/**
* Chooses the label spacing so the labels fill no more than half of the display width.
*/
private Scale getScale(SpectralDisplayModel model, double width)
{
double bandwidth = model.getDisplayBandwidth();
int decimals = 3;
long step = 1;
//Two passes: the label width depends on the number of decimals, which depends on the label spacing
for(int pass = 0; pass < 2; pass++)
{
setPrecision(decimals);
double labelWidth = measure(mLabelFormat.format(model.getDisplayMaxFrequency() / 1E6));
double maxLabels = Math.max(1.0, width / (labelWidth * 2.0));
step = niceStep(bandwidth / maxLabels);
decimals = Math.max(1, Math.min(6, (int)Math.ceil(6.0 - Math.log10(step) - 1E-9)));
}
long mantissa = step / (long)Math.pow(10, Math.floor(Math.log10(step)));
long minor = Math.max(1, step / (mantissa == 2 ? 4 : 5));
return new Scale(step, minor, decimals);
}
/**
* Smallest 1, 2 or 5 times a power of ten that is at least the value, with a minimum of 1 Hertz.
*/
static long niceStep(double value)
{
if(value <= 1)
{
return 1;
}
double power = Math.pow(10, Math.floor(Math.log10(value)));
for(int mantissa: new int[]{1, 2, 5, 10})
{
if(mantissa * power >= value)
{
return Math.round(mantissa * power);
}
}
return Math.round(10 * power);
}
/**
* First multiple of the step at or above the frequency.
*/
private static long firstMultiple(double frequency, long step)
{
return (long)Math.ceil(frequency / step) * step;
}
/**
* Centers a line on a pixel so one pixel wide lines are sharp.
*/
private static double snap(double x)
{
return Math.floor(x) + 0.5;
}
private void setPrecision(int decimals)
{
mLabelFormat.setMinimumFractionDigits(decimals);
mLabelFormat.setMaximumFractionDigits(decimals);
}
private double measure(String text)
{
mMeasure.setText(text);
return mMeasure.getLayoutBounds().getWidth();
}
/**
* Label and tick spacing in Hertz and the number of label decimal places (MHz).
*/
private record Scale(long labelStep, long minorStep, int decimals)
{
}
}

View File

@@ -19,18 +19,23 @@
package io.github.dsheirer.gui.symbol;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.sample.Listener;
import java.net.URL;
import java.util.Arrays;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.ScatterChart;
import javafx.scene.chart.XYChart;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
/**
* Chart display for received QPSK symbols
* Chart display for received QPSK symbols. Colors follow a spectral display theme: the theme background, the
* spectrum trace color for the symbols, and the theme line color for the grid, axes and legend.
*/
public class SymbolView extends ChannelView implements Listener<Float>
{
@@ -57,6 +62,14 @@ public class SymbolView extends ChannelView implements Listener<Float>
mSymbolSeries.getData().add(new XYChart.Data<>(x, 0));
}
getStyleClass().add("symbol-view");
URL stylesheet = SymbolView.class.getResource("/spectral_display.css");
if(stylesheet != null)
{
getStylesheets().add(stylesheet.toExternalForm());
}
mSymbolChart.getData().add(mSymbolSeries);
mSymbolChart.setMaxHeight(Double.MAX_VALUE);
mSymbolChart.setMaxWidth(Double.MAX_VALUE);
@@ -65,6 +78,46 @@ public class SymbolView extends ChannelView implements Listener<Float>
getChildren().add(mSymbolChart);
}
/**
* Applies the theme colors now and whenever the theme changes. JavaFX thread only.
* @param theme to follow, for example the theme shared by the spectral displays
*/
public void followTheme(ObservableValue<SpectralDisplayTheme> theme)
{
theme.addListener((ob, ov, nv) -> setTheme(nv));
setTheme(theme.getValue());
}
/**
* Applies the theme colors to the chart. JavaFX thread only.
*/
public void setTheme(SpectralDisplayTheme theme)
{
if(theme == null)
{
theme = SpectralDisplayTheme.getDefault();
}
Color line = theme.getLine();
Color text = Color.color(line.getRed(), line.getGreen(), line.getBlue());
Color trace = theme.getTrace();
Color symbol = Color.color(trace.getRed(), trace.getGreen(), trace.getBlue(),
Math.max(0.6, trace.getOpacity()));
//Looked-up colors used by the symbol-view rules in spectral_display.css
setStyle("symbol-background: " + toWeb(theme.getBackground()) + ";" +
"symbol-color: " + toWeb(symbol) + ";" +
"symbol-grid: " + toWeb(theme.getLine(0.35)) + ";" +
"symbol-text: " + toWeb(text) + ";");
}
private static String toWeb(Color color)
{
return String.format("#%02x%02x%02x%02x", Math.round(color.getRed() * 255),
Math.round(color.getGreen() * 255), Math.round(color.getBlue() * 255),
Math.round(color.getOpacity() * 255));
}
/**
* Updates the chart axis label with the currently displayed protocol
* @param protocol that is being processed/displayed.

View File

@@ -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
@@ -133,6 +133,7 @@ public class ApplicationLog
Attributes atts = findManifestAttributes();
if (atts != null) {
mLog.info("SDRTrunk Version : " + atts.getValue("Implementation-Version"));
mLog.info("Git Describe : " + atts.getValue("Git-Describe"));
mLog.info("Gradle Version : " + atts.getValue("Created-By"));
mLog.info("Build Timestamp : " + atts.getValue("Build-Timestamp"));
mLog.info("Build-JDK : " + atts.getValue("Build-JDK"));

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

@@ -123,25 +123,23 @@ public class LRRPPacket extends Packet
}
else
{
String message = getMessage().getSubMessage(getOffset(), getOffset() + ((characterCount + 2) * 8)).toHexString();
int x = 0;
RequestedTokens requestedTokens = new RequestedTokens();
while(characterCount > 0 && offset < getMessage().size())
{
String tokenId = getTokenIdentifier(offset);
TokenType tokenType = TokenType.fromValue(tokenId);
TokenType tokenType = TokenType.fromRequestValue(tokenId);
if(tokenType.isRequestParameterToken())
{
Token token = TokenFactory.createToken(tokenId, getMessage(), offset, characterCount);
Token token = TokenFactory.createToken(tokenType, getMessage(), offset, characterCount);
mTokens.add(token);
characterCount -= token.getByteLength();
offset += (8 * token.getByteLength());
}
else
{
//Single-byte flag naming a field that the radio should include in its location report
if(tokenType != TokenType.UNKNOWN)
{
requestedTokens.add(tokenType);

View File

@@ -26,7 +26,7 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
* LRRP Identity Token
*
* Start Token: 0x22
* Total Length: 6 bytes
* Total Length: 2 bytes plus a 1-4 byte request identifier
*/
public class Identity extends Token
{
@@ -34,9 +34,9 @@ public class Identity extends Token
private static final int[] BYTE_VALUE = new int[]{0, 1, 2, 3, 4, 5, 6, 7};
/**
* Constructs an instance of a heading token.
* Constructs an instance of an identity token.
*
* @param message containing the heading
* @param message containing the identity
* @param offset to the start of the token
*/
public Identity(CorrectedBinaryMessage message, int offset)
@@ -50,47 +50,47 @@ public class Identity extends Token
return TokenType.IDENTITY;
}
private BinaryMessage getTokenContents()
/**
* Request identifier bytes, as transmitted. The read is clamped to the end of the message so that a corrupted
* length field cannot produce bytes that were never received.
*/
private BinaryMessage getIdentifierBytes()
{
return getMessage().getSubMessage(getOffset(), getOffset() + 48);
int start = getOffset() + IDENTIFIER_START;
int available = Math.max(0, getMessage().size() - start);
int length = Math.min(getVariableLengthPayloadSize() * 8, available);
return getMessage().getSubMessage(start, start + length);
}
/**
* Extracts the payload contents.
* Request identifier assembled big-endian from the 1-4 payload bytes. Moto.Net (LRRPPacket.cs) and node-dmr-lib
* (LRRP.js) both read the identifier this way.
*
* Note: assumes that the values are 1-4 bytes in big-endian format? Not sure if this is correct because the
* values that I observed only had value in the LSB and the 3 MSBs were all zeros.
* @return identifier, or zero when the payload length is not 1-4 bytes.
*/
public int getID()
{
int length = getVariableLengthPayloadSize();
int payloadOffset = 16;
if(length < 1 || length > 4)
{
return 0;
}
int value = 0;
while(0 < length && length <= 4)
for(int x = 0; x < length; x++)
{
int byteValue = getMessage().getInt(BYTE_VALUE, getOffset() + payloadOffset);
byteValue = Integer.rotateLeft(byteValue, payloadOffset - 16);
value += byteValue;
payloadOffset += 8;
length--;
value = (value << 8) | getMessage().getInt(BYTE_VALUE, getOffset() + IDENTIFIER_START + (x * 8));
}
return value;
}
@Override
public String toString()
{
int id = getID();
if(id != 0)
{
return "ID:" + id;
}
return "ID:" + getTokenContents().toHexString();
//Hexadecimal, as transmitted (e.g. ID:FFFFEE)
return "ID:" + getIdentifierBytes().toHexString();
}
}

View File

@@ -0,0 +1,67 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP request parameter of unknown meaning that carries a 7-bit variable length integer value.
*
* Start Tokens: 0x42 and 0x44
* Total Length: 1 byte identifier plus a variable length integer value (0-5 bytes)
*
* Observed in Motorola P25 triggered location start requests as 0x44 0x64 (100) and 0x42 0x9C 0x10 (3600), placed
* among the requested-field flags. Moto.Net emits a bare 0x42 as a "trigger on GPIO" flag, so the value is treated
* as optional and the token is rendered without a value when none follows.
*/
public class RequestParameter extends VarIntToken
{
private TokenType mTokenType;
/**
* Constructs an instance of a request parameter token.
*
* @param tokenType of the parameter (REQUEST_PARAMETER_42 or REQUEST_PARAMETER_44)
* @param message containing the token
* @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public RequestParameter(TokenType tokenType, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset, remainingCharacterCount);
mTokenType = tokenType;
}
@Override
public TokenType getTokenType()
{
return mTokenType;
}
@Override
public String toString()
{
if(hasValue())
{
return getTokenType() + ":" + getValue();
}
return getTokenType().toString();
}
}

View File

@@ -63,12 +63,28 @@ public class RequestedTokens extends Token
Collections.sort(mUnrecognizedRequestedTokens);
}
/**
* Recognized requested token types, in the order that they appeared in the packet.
*/
public List<TokenType> getRequestedTokens()
{
return mRequestedTokens;
}
/**
* Unrecognized requested token identifiers (hexadecimal), sorted.
*/
public List<String> getUnrecognizedRequestedTokens()
{
return mUnrecognizedRequestedTokens;
}
/**
* Indicates if this object has requested tokens.
*/
public boolean hasRequestedTokens()
{
return !mRequestedTokens.isEmpty();
return !mRequestedTokens.isEmpty() || !mUnrecognizedRequestedTokens.isEmpty();
}
@Override

View File

@@ -28,7 +28,7 @@ import java.util.TimeZone;
/**
* LRRP Timestamp Token
*
* Start Token: 0x34
* Start Token: 0x34 (response packets; in a request packet 0x34 is the Periodic Trigger token)
* Total Length: 6 bytes
*/
public class Timestamp extends Token

View File

@@ -88,6 +88,11 @@ public abstract class Token
{
return getVariableLengthPayloadSize() + 2; //Variable Length Payload + Id + Length Field
}
else if(getTokenType().isVarInt())
{
//Id + 7-bit variable length integer. VarIntToken overrides this to bound the read by the payload.
return VarInt.length(getMessage(), getOffset() + 8, VarInt.MAX_BYTES) + 1;
}
else
{
return getTokenType().getLength() + 1; //Fixed Length Payload + Id

View File

@@ -26,10 +26,32 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
*/
public class TokenFactory
{
/**
* Creates a token using the response packet meaning of the token identifier.
*
* @param id hexadecimal token identifier
* @param message containing the token
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
* @return token
*/
public static Token createToken(String id, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
TokenType tokenType = TokenType.fromValue(id);
return createToken(TokenType.fromValue(id), message, offset, remainingCharacterCount);
}
/**
* Creates a token of the specified type.
*
* @param tokenType resolved by the caller, using TokenType.fromRequestValue() for request packets
* @param message containing the token
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
* @return token
*/
public static Token createToken(TokenType tokenType, CorrectedBinaryMessage message, int offset,
int remainingCharacterCount)
{
switch(tokenType)
{
case HEADING:
@@ -52,14 +74,17 @@ public class TokenFactory
return new Success(message, offset);
case TIMESTAMP:
return new Timestamp(message, offset);
case TRIGGER_CONDITION:
return new TriggerCondition(message, offset);
case TRIGGER_DISTANCE:
return new TriggerDistance(message, offset);
case TRIGGER_GPIO:
return new TriggerGpio(message, offset);
case TRIGGER_ON_MOVE:
return new TriggerOnMove(message, offset);
return new TriggerDistance(message, offset, remainingCharacterCount);
case TRIGGER_INTERVAL:
return new TriggerInterval(message, offset, remainingCharacterCount);
case TRIGGER_PERIODIC:
return new TriggerPeriodic(message, offset);
case REQUEST_PARAMETER_42:
case REQUEST_PARAMETER_44:
return new RequestParameter(tokenType, message, offset, remainingCharacterCount);
case VERSION:
return new Version(message, offset);
case UNKNOWN_23:

View File

@@ -26,21 +26,37 @@ import java.util.Map;
/**
* LRRP Token Types
*
* Variable length tokens consist of an identifier, a length indicator, and then a sequence of bytes equal in length
* to the length indicator.
* Fixed length tokens consist of an identifier followed by a fixed number of value bytes. Variable length tokens
* consist of an identifier, a length indicator, and then a sequence of bytes equal in length to the length indicator.
* Varint tokens consist of an identifier followed by a 7-bit variable length integer (see VarInt).
*
* A few identifiers have a different meaning in a request than in a response (0x34 is the periodic trigger flag in a
* request and the timestamp in a response). Use fromRequestValue() for request packets and fromValue() otherwise.
*
* Note: the declaration order (ordinal) controls the order that tokens are listed when a packet is rendered as text.
*/
public enum TokenType
{
//Tokens common to request and response packets
IDENTITY("22", -1, "IDENTITY"),
UNKNOWN_23("23", 1, "UNKNOWN 23"),
TRIGGER_PERIODIC("31", 1, "TRIGGER PERIODIC"),
//Request parameter tokens. Integer values are 7-bit variable length integers (varints).
TRIGGER_PERIODIC("34", 0, "PERIODIC", true), //Request meaning of 0x34. See TIMESTAMP for the response meaning.
TRIGGER_INTERVAL("31", -2, "TRIGGER INTERVAL"),
TRIGGER_CONDITION("4A", 1, "TRIGGER CONDITION"),
TRIGGER_DISTANCE("78", -2, "TRIGGER DISTANCE"),
REQUEST_PARAMETER_42("42", -2, "PARAM-42"), //Meaning unknown. Observed value: 3600
REQUEST_PARAMETER_44("44", -2, "PARAM-44"), //Meaning unknown. Observed value: 100
REQUEST_61("61", 1, "REQUEST-61"),
REQUEST_73("73", 1, "REQUEST-73"),
//Response tokens and single-byte requested field flags
TIMESTAMP("34", 5, "TIMESTAMP"),
VERSION("36", 1, "VERSION"),
RESPONSE("37", -1, "RESPONSE"),
SUCCESS( "38", 0, "SUCCESS"),
REQUEST_3A("3A", 0,"REQUEST-3A"),
TRIGGER_GPIO("42", 0, "TRIGGER GPIO"),
TRIGGER_DISTANCE("4A", 1, "TRIGGER DISTANCE"), //Guess - not sure
ALTITUDE_ACCURACY("50", 0,"ALTITUDE ACCURACY"),
CIRCLE_2D("51", 10, "CIRCLE 2D"),
TIME("52", 0,"TIME"),
@@ -48,37 +64,55 @@ public enum TokenType
CIRCLE_3D("55", 15, "CIRCLE 3D"),
HEADING("56", 1, "HEADING"),
HORIZONTAL_DIRECTION("57", 0,"HORIZONTAL DIRECTION"),
REQUEST_61("61", 1, "REQUEST-61"),
REQUEST_62("62", 0,"REQUEST-62"),
HORIZONTAL_SPEED("62", 0,"HORIZONTAL SPEED"),
REQUEST_64("64", 0,"REQUEST-64"),
POINT_2D("66", 8, "POINT 2D"),
POINT_3D("69", 11, "POINT 3D"),
SPEED("6C", 2, "SPEED"),
REQUEST_73("73", 1, "REQUEST-73"),
TRIGGER_ON_MOVE("78", 1, "TRIGGER ON MOVE"),
REQUESTED_TOKENS("000", 0, "REQUESTED TOKENS"),
UNKNOWN("0",0, "UNKNOWN");
private static final int VARIABLE_LENGTH = -1;
private static final int VARINT_LENGTH = -2;
private String mValue;
private int mLength;
private String mLabel;
private boolean mRequestOnly;
/**
* Tokens that have parameters in request packet messages
*/
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_DISTANCE, TRIGGER_GPIO,
TRIGGER_ON_MOVE, TRIGGER_PERIODIC, REQUEST_61, REQUEST_73);
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_PERIODIC,
TRIGGER_INTERVAL, TRIGGER_CONDITION, TRIGGER_DISTANCE, REQUEST_PARAMETER_42, REQUEST_PARAMETER_44,
REQUEST_61, REQUEST_73);
/**
* Constructs an instance
* @param value of the token
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token.
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2
* indicates a 7-bit variable length integer (varint) value.
* @param label for pretty printing
*/
TokenType(String value, int length, String label)
{
this(value, length, label, false);
}
/**
* Constructs an instance
* @param value of the token
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2
* indicates a 7-bit variable length integer (varint) value.
* @param label for pretty printing
* @param requestOnly true when this meaning of the value applies to request packets only
*/
TokenType(String value, int length, String label, boolean requestOnly)
{
mValue = value;
mLength = length;
mLabel = label;
mRequestOnly = requestOnly;
}
/**
@@ -94,7 +128,23 @@ public enum TokenType
*/
public boolean isVariableLength()
{
return mLength == -1;
return mLength == VARIABLE_LENGTH;
}
/**
* Indicates if this token's value is a 7-bit variable length integer (varint).
*/
public boolean isVarInt()
{
return mLength == VARINT_LENGTH;
}
/**
* Indicates if this meaning of the token value applies to request packets only.
*/
public boolean isRequestOnly()
{
return mRequestOnly;
}
/**
@@ -116,17 +166,26 @@ public enum TokenType
private static final Map<String,TokenType> LOOKUP_MAP = new HashMap<>();
private static final Map<String,TokenType> REQUEST_LOOKUP_MAP = new HashMap<>();
static
{
for(TokenType tokenType: TokenType.values())
{
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
if(tokenType.isRequestOnly())
{
REQUEST_LOOKUP_MAP.put(tokenType.getValue(), tokenType);
}
else
{
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
REQUEST_LOOKUP_MAP.putIfAbsent(tokenType.getValue(), tokenType);
}
}
}
/**
* Lookup a token from a 2-character string value
* Lookup a token from a 2-character string value, using the response packet meaning of the value.
* @param value that represents the token
* @return matching token or UNKNOWN.
*/
@@ -140,6 +199,21 @@ public enum TokenType
return UNKNOWN;
}
/**
* Lookup a token from a 2-character string value, using the request packet meaning of the value.
* @param value that represents the token
* @return matching token or UNKNOWN.
*/
public static TokenType fromRequestValue(String value)
{
if(value != null && REQUEST_LOOKUP_MAP.containsKey(value))
{
return REQUEST_LOOKUP_MAP.get(value);
}
return UNKNOWN;
}
@Override
public String toString()
{

View File

@@ -0,0 +1,135 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Condition Token
*
* Start Token: 0x4A
* Total Length: 2 bytes
*
* Event that causes the radio to send a triggered location report. Condition values are from node-dmr-lib
* (src/DMRConst.js) and match captured requests: 0x4A 0x02 emergency, 0x4A 0x42 pin status change (telemetry),
* 0x4A 0x50 PTT and 0x4A 0x51 PTT emergency.
*/
public class TriggerCondition extends Token
{
private static final int[] CONDITION = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Trigger condition values
*/
public enum Condition
{
EMERGENCY(0x02, "EMERGENCY"),
PIN_STATUS_CHANGE(0x42, "PIN STATUS CHANGE"),
PTT(0x50, "PTT"),
PTT_EMERGENCY(0x51, "PTT EMERGENCY"),
UNKNOWN(-1, "UNKNOWN");
private int mValue;
private String mLabel;
Condition(int value, String label)
{
mValue = value;
mLabel = label;
}
/**
* Numeric value of the condition
*/
public int getValue()
{
return mValue;
}
/**
* Lookup a condition from a value
* @param value to lookup
* @return matching condition or UNKNOWN
*/
public static Condition fromValue(int value)
{
for(Condition condition: Condition.values())
{
if(condition.getValue() == value)
{
return condition;
}
}
return UNKNOWN;
}
@Override
public String toString()
{
return mLabel;
}
}
/**
* Constructs an instance of a trigger condition token.
*
* @param message containing the token
* @param offset to the start of the token
*/
public TriggerCondition(CorrectedBinaryMessage message, int offset)
{
super(message, offset);
}
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_CONDITION;
}
/**
* Raw condition value
*/
public int getConditionValue()
{
return getMessage().getInt(CONDITION, getOffset());
}
/**
* Trigger condition
*/
public Condition getCondition()
{
return Condition.fromValue(getConditionValue());
}
@Override
public String toString()
{
Condition condition = getCondition();
if(condition == Condition.UNKNOWN)
{
return "TRIGGER CONDITION:UNKNOWN[0x" + String.format("%02X", getConditionValue()) + "]";
}
return "TRIGGER CONDITION:" + condition;
}
}

View File

@@ -24,21 +24,23 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Distance Token
*
* Total Length: 2 bytes
* Start Token: 0x78
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
*
* Distance in meters that the radio must move before it sends a triggered location report, e.g. 0x78 0x83 0x74 = 500.
*/
public class TriggerDistance extends Token
public class TriggerDistance extends VarIntToken
{
private static final int[] DISTANCE = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Constructs an instance of a heading token.
* Constructs an instance of a trigger distance token.
*
* @param message containing the heading
* @param message containing the token
* @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public TriggerDistance(CorrectedBinaryMessage message, int offset)
public TriggerDistance(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
super(message, offset, remainingCharacterCount);
}
@Override
@@ -52,12 +54,12 @@ public class TriggerDistance extends Token
*/
public int getDistance()
{
return getMessage().getInt(DISTANCE, getOffset());
return getValue();
}
@Override
public String toString()
{
return "TRIGGER DISTANCE:" + getDistance();
return "TRIGGER DISTANCE:" + getDistance() + " METERS";
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 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
@@ -16,38 +16,49 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger on GPIO Token
* LRRP Trigger Interval Token
*
* Total Length: 1 byte
* Start Token: 0x31
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
*
* Reporting interval in seconds for a periodic triggered location request, e.g. 0x31 0x84 0x58 = 600 seconds.
*/
public class TriggerGpio extends Token
public class TriggerInterval extends VarIntToken
{
/**
* Constructs an instance of a heading token.
* Constructs an instance of a trigger interval token.
*
* @param message containing the heading
* @param message containing the token
* @param offset to the start of the token
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public TriggerGpio(CorrectedBinaryMessage message, int offset)
public TriggerInterval(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
super(message, offset, remainingCharacterCount);
}
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_GPIO;
return TokenType.TRIGGER_INTERVAL;
}
/**
* Interval in seconds
*/
public int getSeconds()
{
return getValue();
}
@Override
public String toString()
{
return "TRIGGER ON GPIO";
return "INTERVAL:" + getSeconds() + " SECS";
}
}

View File

@@ -22,18 +22,20 @@ package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* LRRP Trigger Periodic Token
* LRRP Periodic Trigger Token (request packets)
*
* Total Length: 2 bytes
* Start Token: 0x34 (in a response packet 0x34 is the Timestamp token)
* Total Length: 1 byte
*
* Flags a triggered location request as periodic. The reporting interval, when present, follows in a separate
* Trigger Interval (0x31) token, and a Trigger Condition (0x4A) or Trigger Distance (0x78) token can follow instead.
*/
public class TriggerPeriodic extends Token
{
private static final int[] SECONDS = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
/**
* Constructs an instance of a heading token.
* Constructs an instance of a periodic trigger token.
*
* @param message containing the heading
* @param message containing the token
* @param offset to the start of the token
*/
public TriggerPeriodic(CorrectedBinaryMessage message, int offset)
@@ -44,20 +46,12 @@ public class TriggerPeriodic extends Token
@Override
public TokenType getTokenType()
{
return TokenType.TRIGGER_DISTANCE;
}
/**
* Seconds for trigger
*/
public int getSeconds()
{
return getMessage().getInt(SECONDS, getOffset());
return TokenType.TRIGGER_PERIODIC;
}
@Override
public String toString()
{
return "TRIGGER EVERY:" + getSeconds() + " SECS";
return "PERIODIC";
}
}

View File

@@ -0,0 +1,105 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.BinaryMessage;
/**
* LRRP variable length integer (varint) reader.
*
* LRRP encodes integer token values as one or more bytes where each byte carries seven value bits, most significant
* group first, and the high bit of a byte indicates that another byte follows. For example 0x84 0x58 decodes to
* (0x04 << 7) | 0x58 = 600, 0x9C 0x10 decodes to (0x1C << 7) | 0x10 = 3600 and a value smaller than 128 occupies a
* single byte (0x64 = 100).
*
* References:
* - Moto.Net, LRRPPacket.cs: "looks like this stuff is using VLQ ... for most of the fields"
* - node-dmr-lib, src/Protocols/LRRP.js: parseLRRPInt() and toLRRPInt()
*/
public final class VarInt
{
/**
* Maximum number of bytes consumed for a single value. Five 7-bit groups hold 35 bits, which covers any 32-bit
* value, and the cap keeps a corrupted run of continuation bits from consuming the rest of a packet.
*/
public static final int MAX_BYTES = 5;
private static final int[] BYTE_VALUE = {0, 1, 2, 3, 4, 5, 6, 7};
private static final int CONTINUATION_MASK = 0x80;
private static final int VALUE_MASK = 0x7F;
private VarInt()
{
//Static utility class
}
/**
* Number of bytes occupied by the varint that starts at the bit offset.
*
* @param message containing the value
* @param offset bit offset to the first byte of the value
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
* @return byte count from 0 to the lesser of limit and MAX_BYTES. Zero indicates that no value is present.
*/
public static int length(BinaryMessage message, int offset, int limit)
{
int maximum = Math.min(limit, MAX_BYTES);
int count = 0;
while(count < maximum)
{
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
count++;
if((b & CONTINUATION_MASK) == 0)
{
break;
}
}
return count;
}
/**
* Decodes the varint that starts at the bit offset.
*
* @param message containing the value
* @param offset bit offset to the first byte of the value
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
* @return decoded value, or zero when no bytes are available.
*/
public static int value(BinaryMessage message, int offset, int limit)
{
int maximum = Math.min(limit, MAX_BYTES);
int value = 0;
for(int count = 0; count < maximum; count++)
{
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
value = (value << 7) | (b & VALUE_MASK);
if((b & CONTINUATION_MASK) == 0)
{
break;
}
}
return value;
}
}

View File

@@ -0,0 +1,88 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
/**
* Base class for LRRP tokens whose value is a 7-bit variable length integer (see VarInt).
*
* The value starts in the byte that follows the token identifier and occupies one or more bytes. The read is
* bounded by the number of payload bytes that remain, so a token at the end of the payload cannot consume bytes
* that belong to the next packet.
*/
public abstract class VarIntToken extends Token
{
private static final int VALUE_START = 8;
private int mRemainingCharacterCount;
/**
* Constructs an instance
*
* @param message containing the token and value
* @param offset to the start of the token identifier
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
*/
public VarIntToken(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
{
super(message, offset);
mRemainingCharacterCount = remainingCharacterCount;
}
/**
* Maximum number of value bytes that can follow the token identifier.
*/
private int getValueLimit()
{
return Math.max(0, mRemainingCharacterCount - 1);
}
/**
* Number of bytes occupied by the value, exclusive of the token identifier.
*/
public int getValueLength()
{
return VarInt.length(getMessage(), getOffset() + VALUE_START, getValueLimit());
}
/**
* Indicates if a value follows the token identifier.
*/
public boolean hasValue()
{
return getValueLength() > 0;
}
/**
* Decoded integer value, or zero when the token has no value.
*/
public int getValue()
{
return VarInt.value(getMessage(), getOffset() + VALUE_START, getValueLimit());
}
/**
* Total token length: identifier plus the variable length value.
*/
@Override
public int getByteLength()
{
return 1 + getValueLength();
}
}

View File

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

View File

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

View File

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

View File

@@ -192,6 +192,14 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
private final P25TrafficChannelManager mTrafficChannelManager;
private ServiceOptions mCurrentServiceOptions;
/**
* Encryption state of the current call as signalled by the algorithm ID in the HDU header or LDU2 encryption sync
* parameters (null when not yet known for this call). Some radios set the encryption flag in the service options of
* the LDU1 voice channel user link control while transmitting clear voice (HDU and LDU2 report ALGID 0x80), so the
* algorithm ID takes precedence over the link control service options when it is known.
*/
private Boolean mCallEncryptedByAlgorithm = null;
/**
* Constructs an APCO-25 decoder state with an optional traffic channel manager.
* @param channel with configuration details
@@ -419,6 +427,12 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw instanceof IServiceOptionsProvider sop)
{
serviceOptions = sop.getServiceOptions();
//Trust the HDU/LDU2 algorithm ID over the link control service options encryption flag when known.
if(serviceOptions != null && serviceOptions.isEncrypted() && Boolean.FALSE.equals(mCallEncryptedByAlgorithm))
{
serviceOptions = VoiceServiceOptions.createUnencrypted(serviceOptions);
}
}
else if(lcw.isEncrypted())
{
@@ -838,6 +852,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
@@ -880,6 +895,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(esp != null && esp.isValid())
{
getIdentifierCollection().update(esp.getIdentifiers());
mCallEncryptedByAlgorithm = esp.isEncryptedAudio();
if(esp.isEncryptedAudio())
{
@@ -903,6 +919,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
*/
private void processTDU(P25P1Message message)
{
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
}
@@ -921,6 +938,7 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
if(lcw != null && lcw.isValid())
{
mCallEncryptedByAlgorithm = null;
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
processLC(lcw, message.getTimestamp(), true);

View File

@@ -34,7 +34,7 @@ public class HDUMessage extends P25P1Message
private final static Logger mLog = LoggerFactory.getLogger(HDUMessage.class);
private static final int[] GOLAY_WORD_STARTS = {0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 198, 216, 234, 252,
270, 288, 306, 324, 342, 360, 278, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
270, 288, 306, 324, 342, 360, 378, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
private static final int[] CW_HEX_0 = {0, 1, 2, 3, 4, 5};
private static final int[] CW_HEX_1 = {18, 19, 20, 21, 22, 23};

View File

@@ -24,6 +24,7 @@ import io.github.dsheirer.module.decode.p25.phase1.message.pdu.ambtc.AMBTCHeader
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketHeader;
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.response.ResponseHeader;
import io.github.dsheirer.module.decode.p25.reference.PDUFormat;
import io.github.dsheirer.module.decode.p25.reference.ServiceAccessPoint;
public class PDUHeaderFactory
{
@@ -41,8 +42,41 @@ public class PDUHeaderFactory
case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseHeader(correctedBinaryMessage, passesCRC);
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
//Format 0x15 is the generic unconfirmed data header. Unconfirmed multi-block trunking control is
//only the trunking control SAP (61/63) use of it. With a packet data SAP the header has the same
//field layout as the confirmed packet data header and the data blocks carry an (IP) packet.
if(isPacketData(getServiceAccessPoint(correctedBinaryMessage)))
{
return new PacketHeader(correctedBinaryMessage, passesCRC);
}
return new PDUHeader(correctedBinaryMessage, passesCRC);
default:
return new PDUHeader(correctedBinaryMessage, passesCRC);
}
}
/**
* Service Access Point (SAP) from a packet data or unconfirmed data header message.
*/
public static ServiceAccessPoint getServiceAccessPoint(CorrectedBinaryMessage correctedBinaryMessage)
{
return ServiceAccessPoint.fromValue(correctedBinaryMessage.getInt(PacketHeader.SAP_ID));
}
/**
* Indicates if the SAP identifies packet data that is processed by the PDUMessageFactory as a packet message.
*/
public static boolean isPacketData(ServiceAccessPoint sap)
{
switch(sap)
{
case UNENCRYPTED_USER_DATA:
case PACKET_DATA:
case SNDCP_PACKET_DATA_CONTROL:
return true;
default:
return false;
}
}
}

View File

@@ -173,6 +173,13 @@ public class PDUMessageFactory
case RESPONSE_PACKET_HEADER_FORMAT:
return new ResponseMessage(pduSequence, nac, timestamp);
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
//The header factory creates a packet header when the unconfirmed data header carries a packet data
//SAP, so that unconfirmed IP packets are processed the same as confirmed (PACKET_DATA) packets.
if(pduSequence.getHeader() instanceof PacketHeader)
{
return createPacketData(pduSequence, nac, timestamp);
}
return createUMBTC(pduSequence, nac, timestamp);
default:
return new PDUSequenceMessage(pduSequence, nac, timestamp);
@@ -229,7 +236,7 @@ public class PDUMessageFactory
}
/**
* Creates a packet data message
* Creates a packet data message from a confirmed or unconfirmed packet data sequence.
*
* @param pduSequence containing a packet data header and zero or more data blocks
* @param nac network access code

View File

@@ -80,4 +80,13 @@ public class VoiceServiceOptions extends ServiceOptions
{
return new VoiceServiceOptions(4);
}
/**
* Creates a copy of the supplied service options with the encryption flag cleared and all other options unchanged.
* @param serviceOptions to copy
*/
public static VoiceServiceOptions createUnencrypted(ServiceOptions serviceOptions)
{
return new VoiceServiceOptions(serviceOptions.mServiceOptions & ~ENCRYPTION_FLAG);
}
}

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
@@ -247,6 +247,7 @@ public class DiagnosticMonitor
Attributes atts = findManifestAttributes();
if (atts != null) {
sb.append("\nVersion : " + atts.getValue("Implementation-Version"));
sb.append("\nGit Describe : " + atts.getValue("Git-Describe"));
sb.append("\nGradle Version : " + atts.getValue("Created-By"));
sb.append("\nBuild Timestamp : " + atts.getValue("Build-Timestamp"));
sb.append("\nBuild-JDK : " + atts.getValue("Build-JDK"));

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
@@ -48,6 +48,7 @@ public class SystemProperties
private static String PROPERTIES_FILENAME = "SDRTrunk.properties";
private static String MANIFEST_VERSION = "Implementation-Version";
private static String BUILD_TIMESTAMP = "Build-Timestamp";
private static String GIT_DESCRIBE = "Git-Describe";
private static SystemProperties INSTANCE;
private static Properties mProperties;
@@ -217,12 +218,18 @@ public class SystemProperties
{
String version = manifest.getMainAttributes().getValue(MANIFEST_VERSION);
String timestamp = manifest.getMainAttributes().getValue(BUILD_TIMESTAMP);
String gitDescribe = manifest.getMainAttributes().getValue(GIT_DESCRIBE);
if(version != null)
{
if(version.contains("nightly") && timestamp != null)
{
sb.append( " nightly - ").append(timestamp);
if(gitDescribe != null && !gitDescribe.equals("unknown"))
{
sb.append(" (").append(gitDescribe).append(")");
}
}
else
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.util;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Detects more than one version of the same library jar on the class path.
*
* The release launch scripts use a 'lib/*' wildcard class path, so when a user unzips a new release over an older one,
* the stale jars from the older release stay on the class path next to the new ones. The JVM loads classes (and the
* tuner libraries extract their native libraries) from whichever jar sorts first, which can silently run an older
* library version, including native code with bugs that the newer version fixed.
*/
public class DuplicateLibraryDetector
{
/**
* Splits a jar file name into the library name and version. The version starts at the first dash that is
* followed by a digit, a 'v' and a digit, or 'nightly' (e.g. guava-33.8.0-jre, api-v2.1.1, sdr-trunk-nightly).
*/
private static final Pattern JAR_NAME = Pattern.compile("^(.+?)-(v?\\d.*|nightly)\\.jar$");
private DuplicateLibraryDetector()
{
}
/**
* Finds the libraries that have more than one version on this JVM's class path.
* @return map of library name to the sorted jar paths for that library, empty when there are no duplicates.
*/
public static Map<String,List<String>> findDuplicates()
{
String classPath = System.getProperty("java.class.path", "");
return findDuplicates(List.of(classPath.split(Pattern.quote(File.pathSeparator))));
}
/**
* Finds the libraries that have more than one version in the class path entries. Entries that are not jar files
* or that don't have a version in the file name are ignored.
* @param classPathEntries to check
* @return map of library name to the sorted jar paths for that library, empty when there are no duplicates.
*/
public static Map<String,List<String>> findDuplicates(List<String> classPathEntries)
{
Map<String,TreeSet<String>> versions = new TreeMap<>();
Map<String,TreeSet<String>> paths = new TreeMap<>();
for(String entry: classPathEntries)
{
if(entry == null || entry.isBlank())
{
continue;
}
Path fileName = Path.of(entry).getFileName();
if(fileName == null)
{
continue;
}
Matcher matcher = JAR_NAME.matcher(fileName.toString());
if(matcher.matches())
{
versions.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(matcher.group(2));
paths.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(entry);
}
}
Map<String,List<String>> duplicates = new TreeMap<>();
for(Map.Entry<String,TreeSet<String>> entry: versions.entrySet())
{
if(entry.getValue().size() > 1)
{
duplicates.put(entry.getKey(), new ArrayList<>(paths.get(entry.getKey())));
}
}
return duplicates;
}
/**
* Creates a description of the duplicate libraries for a log entry or a dialog.
* @param duplicates from findDuplicates()
* @return description, one line per library, listing the jar file names for each library.
*/
public static String describe(Map<String,List<String>> duplicates)
{
StringBuilder sb = new StringBuilder();
for(Map.Entry<String,List<String>> entry: duplicates.entrySet())
{
List<String> names = entry.getValue().stream().map(p -> Path.of(p).getFileName().toString()).toList();
sb.append(" ").append(entry.getKey()).append(": ").append(String.join(", ", names)).append("\n");
}
return sb.toString();
}
/**
* Folder that holds the duplicate jars, for telling the user which folder to replace.
* @param duplicates from findDuplicates()
* @return folder of the first duplicate jar, or null if there are no duplicates or the path has no parent.
*/
public static Path getFolder(Map<String,List<String>> duplicates)
{
if(duplicates.isEmpty())
{
return null;
}
return Path.of(duplicates.values().iterator().next().getFirst()).toAbsolutePath().normalize().getParent();
}
}

View File

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

View File

@@ -0,0 +1,85 @@
/*
* *****************************************************************************
* 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.CorrectedBinaryMessage;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests Golay(24,12) correction of the data bits of words that are not at the start of a 24-bit message.
*/
public class Golay24OffsetTest
{
private static int encode(int data)
{
int checksum = 0;
for(int bit = 0; bit < 12; bit++)
{
if((data & (1 << (11 - bit))) != 0)
{
checksum ^= Golay24.CHECKSUMS[bit];
}
}
int word23 = (data << 11) | checksum;
return (word23 << 1) | (Integer.bitCount(word23) & 1);
}
@Test
public void correctsUpToThreeErrorsInEveryWordOfALongerMessage()
{
Random random = new Random(1);
for(int trial = 0; trial < 2000; trial++)
{
int[] words = new int[3];
CorrectedBinaryMessage message = new CorrectedBinaryMessage(72);
for(int w = 0; w < 3; w++)
{
words[w] = encode(random.nextInt(4096));
int received = words[w];
for(int e = random.nextInt(4); e > 0; e--)
{
received ^= 1 << random.nextInt(24);
}
message.load(w * 24, 24, received);
}
for(int w = 0; w < 3; w++)
{
Golay24.checkAndCorrect(message, w * 24);
}
for(int w = 0; w < 3; w++)
{
//The 12 data bits. (The core correction can leave the overall parity bit wrong when it corrects other
//bits, which no caller reads.)
assertEquals(words[w] >> 12, message.getInt(w * 24, w * 24 + 11), "trial " + trial + " word " + w);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,216 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.Identity;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.RequestParameter;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.RequestedTokens;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.Token;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TokenType;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerCondition;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerInterval;
import io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token.TriggerPeriodic;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* JUnit tests for LRRP request packet token parsing.
*
* The sample packets are Motorola triggered location start requests captured from a P25 system (UDP port 4001).
* Both carry the same requested fields (time, horizontal speed, horizontal direction) and the same two parameters of
* unknown meaning (0x44 = 100, 0x42 = 3600); one is a periodic trigger every 600 seconds and the other is an
* emergency trigger.
*/
public class LRRPPacketTest
{
//Periodic profile: ID FFFFEE, periodic trigger with a 600 second interval (0x31 0x84 0x58)
private static final String PERIODIC_REQUEST = "09112203FFFFEE524464429C10625734318458";
//Condition profile: ID FFFF02, trigger condition emergency (0x4A 0x02)
private static final String EMERGENCY_REQUEST = "09102203FFFF02524464429C106257344A02";
//PERIODIC_REQUEST as received with the packet type byte corrupted to 0xA3 (unknown type)
private static final String UNKNOWN_TYPE_REQUEST = "A3112203FFFFEE524464429C10625734318458";
//EMERGENCY_REQUEST with the packet type byte corrupted to 0x07 (immediate location response)
private static final String DAMAGED_TYPE_REQUEST = "07102203FFFF02524464429C106257344A02";
private static final List<TokenType> REQUESTED_FIELDS = List.of(TokenType.TIME, TokenType.HORIZONTAL_SPEED,
TokenType.HORIZONTAL_DIRECTION);
private static LRRPPacket parse(String hex)
{
return new LRRPPacket(CorrectedBinaryMessage.loadHex(hex), 0);
}
private static <T extends Token> T getToken(LRRPPacket packet, Class<T> clazz, TokenType tokenType)
{
for(Token token: packet.getTokens())
{
if(token.getTokenType() == tokenType && clazz.isInstance(token))
{
return clazz.cast(token);
}
}
fail("Packet does not contain token " + tokenType + ": " + packet);
return null;
}
private static boolean hasToken(LRRPPacket packet, TokenType tokenType)
{
for(Token token: packet.getTokens())
{
if(token.getTokenType() == tokenType)
{
return true;
}
}
return false;
}
/**
* Number of payload bytes accounted for by the parsed tokens: parameter tokens contribute their byte length and
* each requested field flag contributes one byte.
*/
private static int getConsumedBytes(LRRPPacket packet)
{
int consumed = 0;
for(Token token: packet.getTokens())
{
if(token instanceof RequestedTokens requested)
{
consumed += requested.getRequestedTokens().size() + requested.getUnrecognizedRequestedTokens().size();
}
else
{
consumed += token.getByteLength();
}
}
return consumed;
}
/**
* Asserts the fields that both sample requests share.
*/
private static void assertCommonRequestTokens(LRRPPacket packet, int expectedId)
{
Identity identity = getToken(packet, Identity.class, TokenType.IDENTITY);
assertEquals(expectedId, identity.getID());
assertEquals(5, identity.getByteLength());
assertNotNull(getToken(packet, TriggerPeriodic.class, TokenType.TRIGGER_PERIODIC));
RequestParameter parameter42 = getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_42);
assertEquals(3600, parameter42.getValue()); //0x42 0x9C 0x10
assertEquals(3, parameter42.getByteLength());
RequestParameter parameter44 = getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_44);
assertEquals(100, parameter44.getValue()); //0x44 0x64
assertEquals(2, parameter44.getByteLength());
RequestedTokens requested = getToken(packet, RequestedTokens.class, TokenType.REQUESTED_TOKENS);
assertEquals(REQUESTED_FIELDS, requested.getRequestedTokens());
assertTrue(requested.getUnrecognizedRequestedTokens().isEmpty(), "No stray tokens: " + packet);
//Every payload byte is accounted for exactly once
assertEquals(packet.getHeader().getPayloadLength(), getConsumedBytes(packet));
}
@Test
public void periodicTriggeredLocationStartRequest()
{
LRRPPacket packet = parse(PERIODIC_REQUEST);
assertEquals(LRRPPacketType.TRIGGERED_LOCATION_START_REQUEST, packet.getHeader().getLRRPPacketType());
assertEquals(17, packet.getHeader().getPayloadLength());
assertCommonRequestTokens(packet, 0xFFFFEE);
TriggerInterval interval = getToken(packet, TriggerInterval.class, TokenType.TRIGGER_INTERVAL);
assertEquals(600, interval.getSeconds()); //0x31 0x84 0x58
assertEquals(3, interval.getByteLength());
assertFalse(hasToken(packet, TokenType.TRIGGER_CONDITION));
assertEquals("LRRP TRIGGERED LOCATION START REQUEST ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 " +
"PARAM-44:100 REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]", packet.toString());
}
@Test
public void emergencyTriggeredLocationStartRequest()
{
LRRPPacket packet = parse(EMERGENCY_REQUEST);
assertEquals(LRRPPacketType.TRIGGERED_LOCATION_START_REQUEST, packet.getHeader().getLRRPPacketType());
assertEquals(16, packet.getHeader().getPayloadLength());
assertCommonRequestTokens(packet, 0xFFFF02);
TriggerCondition condition = getToken(packet, TriggerCondition.class, TokenType.TRIGGER_CONDITION);
assertEquals(TriggerCondition.Condition.EMERGENCY, condition.getCondition()); //0x4A 0x02
assertEquals(2, condition.getByteLength());
assertFalse(hasToken(packet, TokenType.TRIGGER_INTERVAL));
assertEquals("LRRP TRIGGERED LOCATION START REQUEST ID:FFFF02 PERIODIC TRIGGER CONDITION:EMERGENCY " +
"PARAM-42:3600 PARAM-44:100 REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]",
packet.toString());
}
/**
* A corrupted type byte that is not a known packet type takes the request parsing path, so the intact body still
* decodes completely.
*/
@Test
public void unknownTypeRequestBodyStillDecodes()
{
LRRPPacket packet = parse(UNKNOWN_TYPE_REQUEST);
assertEquals(LRRPPacketType.UNKNOWN, packet.getHeader().getLRRPPacketType());
assertEquals(0xA3, packet.getHeader().getLrrpPacketTypeValue());
assertCommonRequestTokens(packet, 0xFFFFEE);
assertEquals(600, getToken(packet, TriggerInterval.class, TokenType.TRIGGER_INTERVAL).getSeconds());
assertEquals("LRRP TYPE:UNKNOWN [163] ID:FFFFEE PERIODIC INTERVAL:600 SECS PARAM-42:3600 PARAM-44:100 " +
"REQUESTED TOKENS [TIME,HORIZONTAL SPEED,HORIZONTAL DIRECTION]", packet.toString());
}
/**
* A type byte corrupted to a valid response type (0x07) sends the body down the response parsing path, where
* 0x34 is a timestamp rather than the periodic trigger flag. The parse must still terminate within the payload
* and the identity and variable length integer parameters must still decode.
*/
@Test
public void damagedTypeRequestParsesAsResponseWithoutError()
{
LRRPPacket packet = parse(DAMAGED_TYPE_REQUEST);
assertEquals(LRRPPacketType.IMMEDIATE_LOCATION_RESPONSE, packet.getHeader().getLRRPPacketType());
assertDoesNotThrow(packet::toString);
assertEquals(0xFFFF02, getToken(packet, Identity.class, TokenType.IDENTITY).getID());
assertEquals(3600, getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_42).getValue());
assertEquals(100, getToken(packet, RequestParameter.class, TokenType.REQUEST_PARAMETER_44).getValue());
}
}

View File

@@ -0,0 +1,88 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
import io.github.dsheirer.bits.CorrectedBinaryMessage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the LRRP 7-bit variable length integer reader.
*/
public class VarIntTest
{
private static CorrectedBinaryMessage hex(String hex)
{
return CorrectedBinaryMessage.loadHex(hex);
}
@Test
public void singleByteValue()
{
CorrectedBinaryMessage message = hex("64");
assertEquals(1, VarInt.length(message, 0, 10));
assertEquals(100, VarInt.value(message, 0, 10));
}
@Test
public void twoByteValues()
{
//Values captured from Motorola triggered location start requests (interval 600 s, unknown parameter 3600)
assertEquals(2, VarInt.length(hex("8458"), 0, 10));
assertEquals(600, VarInt.value(hex("8458"), 0, 10));
assertEquals(2, VarInt.length(hex("9C10"), 0, 10));
assertEquals(3600, VarInt.value(hex("9C10"), 0, 10));
//Values from the TRBONet captures quoted in node-dmr-lib LRRP.js: interval 250 s and distance 500 m
assertEquals(250, VarInt.value(hex("817A"), 0, 10));
assertEquals(500, VarInt.value(hex("8374"), 0, 10));
}
@Test
public void valueAtOffset()
{
//Token identifier 0x31 followed by the value - read starting at bit 8
CorrectedBinaryMessage message = hex("318458");
assertEquals(2, VarInt.length(message, 8, 2));
assertEquals(600, VarInt.value(message, 8, 2));
}
@Test
public void limitBoundsTheRead()
{
CorrectedBinaryMessage message = hex("8458");
//Only one byte available: the continuation bit is set but the read stops at the limit
assertEquals(1, VarInt.length(message, 0, 1));
assertEquals(4, VarInt.value(message, 0, 1));
//No bytes available
assertEquals(0, VarInt.length(message, 0, 0));
assertEquals(0, VarInt.value(message, 0, 0));
}
@Test
public void maximumLength()
{
//A run of continuation bits longer than MAX_BYTES stops at MAX_BYTES
CorrectedBinaryMessage message = hex("FFFFFFFFFFFFFF");
assertEquals(VarInt.MAX_BYTES, VarInt.length(message, 0, 10));
}
}

View File

@@ -0,0 +1,314 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.p25;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.event.ChannelStartProcessingRequest;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.module.decode.event.IDecodeEvent;
import io.github.dsheirer.module.decode.p25.identifier.channel.APCO25Channel;
import io.github.dsheirer.module.decode.p25.identifier.channel.P25Channel;
import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25RadioIdentifier;
import io.github.dsheirer.module.decode.p25.identifier.talkgroup.APCO25Talkgroup;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.message.IFrequencyBand;
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.Opcode;
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
import io.github.dsheirer.module.decode.p25.phase2.message.mac.MacOpcode;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.p25.reference.ServiceOptions;
import io.github.dsheirer.module.decode.p25.reference.VoiceServiceOptions;
import io.github.dsheirer.source.config.SourceConfigTuner;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the ignore encrypted calls option of the P25 traffic channel manager.
*/
public class P25TrafficChannelManagerEncryptedTest
{
private static final long BASE = 851_000_000L;
private static final long SPACING = 12_500L;
private static final int TALKGROUP_A = 100;
private static final int TALKGROUP_B = 200;
/**
* Captures traffic channel start requests and decode events.
*/
private static class Harness
{
private final P25TrafficChannelManager mManager;
private final List<Long> mStartedFrequencies = new ArrayList<>();
private final List<IDecodeEvent> mEvents = new ArrayList<>();
Harness(DecodeConfigP25 config)
{
Channel parent = new Channel("control");
parent.setDecodeConfiguration(config);
mManager = new P25TrafficChannelManager(parent);
EventBus eventBus = new EventBus();
eventBus.register(this);
mManager.setInterModuleEventBus(eventBus);
mManager.addDecodeEventListener(mEvents::add);
}
@Subscribe
public void start(ChannelStartProcessingRequest request)
{
mStartedFrequencies.add(request.getChannel().getSourceConfiguration() instanceof
SourceConfigTuner sct ? sct.getFrequency() : -1L);
}
String lastDetails()
{
return mEvents.get(mEvents.size() - 1).getDetails();
}
}
private static DecodeConfigP25Phase1 phase1(boolean ignoreEncrypted)
{
DecodeConfigP25Phase1 config = new DecodeConfigP25Phase1();
config.setIgnoreEncryptedCalls(ignoreEncrypted);
return config;
}
private static DecodeConfigP25Phase2 phase2(boolean ignoreEncrypted)
{
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
config.setIgnoreEncryptedCalls(ignoreEncrypted);
return config;
}
/**
* Frequency band where the downlink frequency for channel number N is BASE + (N / timeslots) * SPACING
*/
private static IFrequencyBand band(int timeslots)
{
return new IFrequencyBand()
{
@Override public boolean isValid() {return true;}
@Override public int getIdentifier() {return timeslots == 1 ? 1 : 2;}
@Override public long getChannelSpacing() {return SPACING;}
@Override public long getBaseFrequency() {return BASE;}
@Override public int getBandwidth() {return 12500;}
@Override public long getTransmitOffset() {return 0;}
@Override public long getDownlinkFrequency(int channelNumber) {return BASE + (channelNumber / timeslots) * SPACING;}
@Override public long getUplinkFrequency(int channelNumber) {return getDownlinkFrequency(channelNumber);}
@Override public boolean isTDMA() {return timeslots > 1;}
@Override public int getTimeslotCount() {return timeslots;}
};
}
private static APCO25Channel fdma(int channelNumber)
{
P25Channel channel = new P25Channel(1, channelNumber);
channel.setFrequencyBand(band(1));
return new APCO25Channel(channel);
}
/**
* @param logicalChannel number (ie frequency index)
* @param timeslot 1 or 2
*/
private static APCO25Channel tdma(int logicalChannel, int timeslot)
{
P25Channel channel = new P25Channel(2, logicalChannel * 2 + (timeslot - 1));
channel.setFrequencyBand(band(2));
return new APCO25Channel(channel);
}
private static MutableIdentifierCollection ic(int talkgroup, Integer radio)
{
MutableIdentifierCollection mic = new MutableIdentifierCollection();
mic.update(APCO25Talkgroup.create(talkgroup));
if(radio != null)
{
mic.update(APCO25RadioIdentifier.createFrom(radio));
}
return mic;
}
private static void p1Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts)
{
h.mManager.processP1ControlDirectedChannelGrant(channel, so, ic(tg, radio), Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT, ts, "test");
}
private static void p1Update(Harness h, APCO25Channel channel, int tg, long ts)
{
h.mManager.processP1ControlAnnouncedTrafficUpdate(channel, null, ic(tg, null),
Opcode.OSP_GROUP_VOICE_CHANNEL_GRANT_UPDATE, ts, "test");
}
private static void p2Grant(Harness h, APCO25Channel channel, ServiceOptions so, int tg, Integer radio, long ts)
{
h.mManager.processP2ChannelGrant(channel, so, ic(tg, radio), MacOpcode.PHASE1_C0_GROUP_VOICE_CHANNEL_GRANT_EXPLICIT, ts, "test");
}
/**
* Mirrors the decoder which uses empty (ie unencrypted) service options for updates that don't carry them.
*/
private static void p2Update(Harness h, APCO25Channel channel, int tg, long ts)
{
h.mManager.processP2ChannelUpdate(channel, new VoiceServiceOptions(0), ic(tg, null),
MacOpcode.PHASE1_42_GROUP_VOICE_CHANNEL_GRANT_UPDATE_IMPLICIT, ts, "test");
}
@Test
void phase1EncryptedGrantIsTunedWhenOptionDisabled()
{
Harness h = new Harness(phase1(false));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase1EncryptedGrantIsIgnoredIncludingLaterUpdates()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p1Update(h, fdma(10), TALKGROUP_A, 1500);
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 2000);
assertTrue(h.mStartedFrequencies.isEmpty());
assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails());
}
@Test
void phase1ClearGrantIsTuned()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase1DifferentTalkerWithClearGrantIsTuned()
{
Harness h = new Harness(phase1(true));
p1Grant(h, fdma(10), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p1Grant(h, fdma(10), VoiceServiceOptions.createUnencrypted(), TALKGROUP_A, 2, 1500);
assertEquals(List.of(BASE + 10 * SPACING), h.mStartedFrequencies);
}
@Test
void phase2EncryptedGrantIsIgnoredIncludingLaterUpdates()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
p2Update(h, tdma(5, 1), TALKGROUP_A, 1500);
assertTrue(h.mStartedFrequencies.isEmpty());
assertTrue(h.lastDetails().startsWith("IGNORED: ENCRYPTED"), h.lastDetails());
}
@Test
void phase2ClearCallOnOtherTimeslotTunesChannel()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertTrue(h.mStartedFrequencies.isEmpty());
p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1100);
assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies);
//Further updates on either timeslot don't request another channel
p2Update(h, tdma(5, 1), TALKGROUP_A, 1500);
p2Update(h, tdma(5, 2), TALKGROUP_B, 1500);
assertEquals(1, h.mStartedFrequencies.size());
}
@Test
void phase2EncryptedGrantOnAlreadyTunedChannelIsNotLabeledIgnored()
{
Harness h = new Harness(phase2(true));
p2Grant(h, tdma(5, 2), VoiceServiceOptions.createUnencrypted(), TALKGROUP_B, 2, 1000);
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1100);
assertEquals(1, h.mStartedFrequencies.size());
assertTrue(h.lastDetails().startsWith("PHASE 2 CHANNEL GRANT"), h.lastDetails());
}
@Test
void phase2EncryptedGrantIsTunedWhenOptionDisabled()
{
Harness h = new Harness(phase2(false));
p2Grant(h, tdma(5, 1), VoiceServiceOptions.createEncrypted(), TALKGROUP_A, 1, 1000);
assertEquals(List.of(BASE + 5 * SPACING), h.mStartedFrequencies);
}
private static Set<IDecodeEvent> distinctEvents(Harness h, int timeslot)
{
Set<IDecodeEvent> events = Collections.newSetFromMap(new IdentityHashMap<>());
for(IDecodeEvent event : h.mEvents)
{
if(event.getTimeslot() == timeslot)
{
events.add(event);
}
}
return events;
}
@Test
void phase2IgnoredDataCallOnTimeslot2IsTrackedOnTimeslot2()
{
DecodeConfigP25Phase2 config = phase2(false);
config.setIgnoreDataCalls(true);
Harness h = new Harness(config);
h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1),
Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1000, "test");
h.mManager.processP1ControlDirectedChannelGrant(tdma(5, 2), null, ic(TALKGROUP_A, 1),
Opcode.OSP_GROUP_DATA_CHANNEL_GRANT, 1500, "test");
assertTrue(h.mStartedFrequencies.isEmpty());
assertEquals(1, distinctEvents(h, 2).size(), "repeated grant should update the timeslot 2 event");
assertTrue(distinctEvents(h, 1).isEmpty());
}
@Test
void phase2DataChannelBroadcastsBothTimeslotEvents()
{
Harness h = new Harness(phase2(false));
h.mManager.processP2DataChannel(tdma(5, 1), 1000);
assertEquals(1, distinctEvents(h, 1).size());
assertEquals(1, distinctEvents(h, 2).size());
}
@Test
void phase2CopyKeepsTrafficChannelSettings()
{
DecodeConfigP25Phase2 original = phase2(true);
original.setIgnoreDataCalls(true);
original.setTrafficChannelPoolSize(7);
DecodeConfigP25Phase2 copy = (DecodeConfigP25Phase2)DecoderFactory.copy(original);
assertTrue(copy.getIgnoreDataCalls());
assertTrue(copy.getIgnoreEncryptedCalls());
assertEquals(7, copy.getTrafficChannelPoolSize());
DecodeConfigP25Phase2 defaults = (DecodeConfigP25Phase2)DecoderFactory.copy(phase2(false));
assertFalse(defaults.getIgnoreDataCalls());
assertFalse(defaults.getIgnoreEncryptedCalls());
}
}

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