Compare commits

..

28 Commits

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

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

* wip: network tuner configs

* wip stash

* working tcp solution

* fix copyright headers

* fix: resolve rebase conflicts from jRTL library changeover

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

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

Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-14 09:37:29 -05:00
Denny Sheirer
9641d2ef59 #736 New JavaFX spectral display and waterfall for both tuner and channel spectrums. (#2637)
* Popout configuration panel (top-left of display)
* Themed color support
* Reworked signal averating/smoothing
* Optional decibel overlay with corrected decibel calculations
* Integrated noise floor slider in  channel view
* Traffic channel overlays
* Popup channel information when hovering over playlist channels.
* Removes legacy swing components.

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

Co-authored-by: Michael Chambers <michaelchambers@Michaels-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-13 04:54:08 -05:00
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
Steve Pinkham
e7f4f655e5 C4FM: bounds check the start of the buffer in the sync score (#2622)
A lagging sync detect on the first symbol after a buffer reload, followed by a
coarse timing search that steps all the way left, scores a candidate whose first
sync symbol is one sample before the buffer start. score() only checked the end
of the buffer, so it threw ArrayIndexOutOfBoundsException: Index -1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:22:44 -05:00
Denny Sheirer
000ac51ff0 #2621 Windows rtl-sdr crashing on retune documented in issue https://github.com/DSheirer/jRTL/issues/4 (#2624)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-11 03:16:02 -05:00
Denny Sheirer
23cee388de #2616 Gradle Runtime plugin configuration and remove local JDK support in the build script (#2617)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-10 03:42:46 -05:00
Denny Sheirer
5c73fb318f #2614 Gradle not including JFXPanel in build artifacts (#2615)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-10 03:09:41 -05:00
Denny Sheirer
066554393f #2612 Strips unused OS/arch tuner libraries from final build artifacts
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-10 02:46:03 -05:00
Denny Sheirer
303c0a98cc #2610 gradle script cleanup (#2611)
* #2610 Gradle 9.9.0 and build script cleanup

---------

Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-10 02:15:51 -05:00
Denny Sheirer
02c62d012f #2557 DMR improved error detection in voice frames and voice events. (#2608)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-09 03:49:58 -05:00
278 changed files with 25875 additions and 12269 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 {
@@ -95,35 +131,34 @@ test {
dependencies {
// JUnit Tests
testImplementation(platform("org.junit:junit-bom:6.0.1"))
testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation 'org.junit.jupiter:junit-jupiter-api'
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
//Jitpack imports
implementation 'com.github.dnault:libresample4j:master'
implementation 'com.github.DSheirer.jmbe:api:v2.1.0'
implementation 'com.github.DSheirer.jmbe:api:v2.1.1'
implementation 'com.github.nwaldispuehl:java-lame:v3.98.4'
//mavenCentral/jcenter imports
implementation 'ch.qos.logback:logback-classic:1.6.3'
implementation 'ch.qos.logback:logback-core:1.5.38'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.22.4'
implementation 'com.fazecast:jSerialComm:2.11.4'
implementation 'com.formdev:flatlaf:3.7.2'
implementation 'com.formdev:flatlaf-intellij-themes:3.7.2'
implementation 'ch.qos.logback:logback-classic:1.6.6'
implementation 'ch.qos.logback:logback-core:1.6.6'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.23.0'
implementation 'com.formdev:flatlaf:3.8'
implementation 'com.formdev:flatlaf-intellij-themes:3.8' //Not imported - Theme.java loads these themes by class name
implementation 'com.github.jiconfont:jiconfont-font_awesome:4.7.0.1'
implementation 'com.github.jiconfont:jiconfont-javafx:1.0.0'
implementation 'com.github.jiconfont:jiconfont-swing:1.0.1'
implementation 'com.github.wendykierp:JTransforms:3.2'
implementation 'com.google.code.gson:gson:2.14.0'
implementation 'com.google.guava:guava:33.6.0-jre'
implementation('com.github.wendykierp:JTransforms:3.2') {
exclude group: 'junit' //JLargeArrays 1.7 declares junit as a compile dependency
}
implementation 'com.google.code.gson:gson:2.15.0'
implementation 'com.google.guava:guava:33.8.0-jre'
implementation 'com.jidesoft:jide-oss:3.6.18'
implementation 'com.miglayout:miglayout-swing:11.4.3'
implementation 'com.miglayout:miglayout-swing:11.5.2'
implementation 'com.mpatric:mp3agic:0.9.1'
implementation 'commons-io:commons-io:2.22.0'
implementation 'eu.hansolo:charts:1.0.5'
implementation 'io.github.dsheirer:jairspy:1.0.12.1' //Airspy tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jairspyhf:1.8.1.1' //Airspy HF+ tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jbladerf:2.6.0.1' //bladeRF tuners - GitHub release, see settings.gradle
@@ -131,20 +166,18 @@ dependencies {
implementation 'io.github.dsheirer:jlibusb:1.0.30.1' //libusb and USB hotplug for the tuner libraries - GitHub release; not pulled in transitively
implementation 'io.github.dsheirer:jhydrasdr:1.1.4.1' //HydraSDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jrtl:1.4.0.2' //RTL-SDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
implementation 'net.coderazzi:tablefilter-swing:5.5.4'
implementation 'org.apache.commons:commons-compress:1.28.0'
implementation 'org.apache.commons:commons-lang3:3.20.0'
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
implementation 'org.apache.commons:commons-lang3:3.21.0'
implementation 'org.apache.commons:commons-math3:3.6.1'
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation 'org.apache.mina:mina-core:2.2.9'
implementation 'org.apache.mina:mina-http:2.2.9'
implementation 'org.controlsfx:controlsfx:11.2.3'
implementation 'org.apache.mina:mina-core:2.2.10'
implementation 'org.apache.mina:mina-http:2.2.10'
implementation 'org.controlsfx:controlsfx:11.2.6'
implementation 'org.rauschig:jarchivelib:1.2.0'
implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'pl.edu.icm:JLargeArrays:1.6'
implementation 'org.slf4j:slf4j-api:2.0.20'
}
def os = DefaultNativePlatform.currentOperatingSystem
@@ -205,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']}",
@@ -235,18 +269,6 @@ String targetOsxX86_64 = 'osx-x86_64-v' + version
String targetWindowsX86_64 = 'windows-x86_64-v' + version
String targetWindowsAarch64 = 'windows-aarch64-v' + version
/**
* Optional pre-downloaded Java Development Kit (JDK) for each target OS and CPU. These locations are hard-coded to the
* author's development environment, but can be changed to your environment if you want to use this build pattern.
*/
String jdk_base = '/home/denny/java_jdks/'
String jdk_linux_aarch64 = jdk_base + 'linux-arm64/jdk-25.0.1-full'
String jdk_linux_x86_64 = jdk_base + 'linux-x64/jdk-25.0.1-full'
String jdk_osx_x86_64 = jdk_base + 'osx-x64/jdk-25.0.1-full.jdk'
String jdk_osx_aarch64 = jdk_base + 'osx-arm64/jdk-25.0.1-full.jdk'
String jdk_windows_x86_64 = jdk_base + 'windows-x64/jdk-25.0.1-full'
String jdk_windows_aarch64 = jdk_base + 'windows-arm64/jdk-25.0.1-full'
/**
* Download URLs to download the JDK as part of the gradle build packaging process
*/
@@ -275,19 +297,138 @@ def configure(RuntimeZipTask rt, List<String> jvmArgs) {
//jdk.incubator.vector - needed for Project Panama foreign function and vector apis
//jdk.accessibility is used with assistive technologies like screen readers
//java.management for JVM resource monitoring
rt.extension.addModules('jdk.charsets', 'jdk.crypto.ec', 'jdk.incubator.vector', 'jdk.accessibility', 'java.management')
//java.base, java.logging, java.net.http, java.prefs, javafx.controls, javafx.swing - required by sdrtrunk (jdeps)
rt.extension.addModules('jdk.charsets', 'jdk.crypto.ec', 'jdk.incubator.vector', 'jdk.accessibility', 'java.management',
'java.base', 'java.logging', 'java.net.http', 'java.prefs', 'javafx.controls', 'javafx.swing')
//Use auto-detected modules and 'add' any specified modules.
rt.extension.additive.set(true)
//Use only the modules listed above. The runtime plugin's module auto-detection uses a bundled ASM library that
//can't read Java 27 class files and logs a 'Failed to scan' error for each sdrtrunk class. The jdeps module check
//on the runtime task verifies that this list is complete.
rt.extension.additive.set(false)
//Recommended options for reducing/shrinking runtime target image size
rt.extension.addOptions('--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages')
rt.extension.addOptions('--strip-debug', '--compress', 'zip-6', '--no-header-files', '--no-man-pages')
//Runtime images have auto appended version in image zip name. Override to include version in single target image.
// rt.extension.imageDir=layout.buildDirectory.dir('image')
rt.extension.imageZip.set(file(layout.buildDirectory.dir('image').get().file('sdr-trunk.zip')))
}
/**
* Rewrites the jar file without the native library folders for other operating systems and architectures. Fails the
* build if a jar that contains native libraries doesn't contain the folder for the target.
* @param jar file to rewrite
* @param keep native folder for the target
* @return number of bytes removed from the jar
*/
long stripJar(File jar, String keep) {
def tunerPattern = ~/^[^\/]+\/native\/([^\/]+)\/.*/
def removed = [] as Set
def hadTuner = false, keptTuner = false
File temp = new File(jar.parentFile, jar.name + '.tmp')
new java.util.zip.ZipFile(jar).withCloseable { zip ->
def entries = zip.entries().toList()
entries.each { e ->
def tuner = tunerPattern.matcher(e.name)
if(tuner.matches()) {
hadTuner = true
if(tuner.group(1) == keep) { keptTuner = true } else { removed << e.name }
}
}
if(hadTuner && !keptTuner) {
throw new GradleException("Native strip: ${jar.name} has no native/${keep} folder")
}
if(!removed.isEmpty()) {
new java.util.zip.ZipOutputStream(new FileOutputStream(temp)).withCloseable { out ->
entries.findAll { !removed.contains(it.name) }.each { e ->
def copy = new java.util.zip.ZipEntry(e)
if(copy.method == java.util.zip.ZipEntry.DEFLATED) {
copy.compressedSize = -1
}
out.putNextEntry(copy)
zip.getInputStream(e).withCloseable { out << it }
out.closeEntry()
}
}
}
}
if(removed.isEmpty()) {
return 0
}
long before = jar.length()
java.nio.file.Files.move(temp.toPath(), jar.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING)
return before - jar.length()
}
/**
* Removes the native libraries for other operating systems and architectures from the jars in each runtime target
* image, before the runtimeZip tasks package the images. The tuner libraries (jRTL, jAirspy, etc.) package their
* native libraries under '<library>/native/<os>-<arch>/', where the folder name matches the runtime target name
* '<os>-<arch>-v<version>' without the version suffix, except that the libraries use 'macos' where the target uses 'osx'.
*/
tasks.named('runtime') {rt ->
rt.doLast {
rt.targetPlatforms.get().keySet().each { target ->
def nativeFolder = (target - "-v${version}").replaceFirst(/^osx-/, 'macos-')
def lib = new File(rt.imageDir.asFile, "${project.name}-${target}/lib")
long total = 0
lib.listFiles({ File f -> f.name.endsWith('.jar') } as FileFilter).each { jar ->
total += stripJar(jar, nativeFolder)
}
logger.lifecycle("Native strip: removed ${String.format('%.1f', total / 1048576)} MB of other " +
"platform native libraries from [${target}]")
}
}
}
/**
* Verifies that each runtime target image contains every module that the sdrtrunk jar requires, as reported by jdeps.
* This guards against the runtime plugin's module auto-detection silently missing modules (e.g. when it can't parse
* class files compiled for a newer Java version).
*/
def jdepsHome = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath.asFile }
def mainJar = tasks.named('jar').flatMap { it.archiveFile }
tasks.named('runtime') {rt ->
rt.doLast {
def jdeps = new File(jdepsHome.get(), 'bin/' + (os.isWindows() ? 'jdeps.exe' : 'jdeps')).absolutePath
def process = new ProcessBuilder(jdeps, '--multi-release', java.toolchain.languageVersion.get().toString(),
'--ignore-missing-deps', '--print-module-deps', '-q', mainJar.get().asFile.absolutePath)
.redirectErrorStream(true).start()
def output = process.inputStream.text.trim()
if(process.waitFor() != 0) {
throw new GradleException("Module check: jdeps failed: ${output}")
}
def required = output.split(',')*.trim().findAll { it } as Set
rt.targetPlatforms.get().keySet().each { target ->
def release = new File(rt.imageDir.asFile, "${project.name}-${target}/release")
def match = release.text =~ /(?m)^MODULES="([^"]*)"/
if(!match.find()) {
throw new GradleException("Module check: no MODULES entry in ${release}")
}
def missing = required - (match.group(1).split(' ') as Set)
if(missing) {
throw new GradleException("Module check: runtime image [${target}] is missing required modules " +
"${missing.sort()} - add them to addModules() in build.gradle")
}
}
logger.lifecycle("Module check: runtime images contain all modules required by sdrtrunk: ${required.sort()}")
}
}
/**
* Creates a release package for the current operating system.
*
@@ -344,22 +485,12 @@ tasks.register('runtimeZipCurrent', RuntimeZipTask) {rt ->
*/
tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
//Windows aarch64
if(file(jdk_windows_aarch64).exists()) {
rt.extension.targetPlatform(targetWindowsAarch64, jdk_windows_aarch64, [])
}
else {
rt.extension.targetPlatform(targetWindowsAarch64) {
jdkHome = jdkDownload(jdk_download_windows_aarch64)
}
rt.extension.targetPlatform(targetWindowsAarch64) {
jdkHome = jdkDownload(jdk_download_windows_aarch64)
}
//Windows x86-64
if(file(jdk_windows_x86_64).exists()) {
rt.extension.targetPlatform(targetWindowsX86_64, jdk_windows_x86_64, [])
}
else {
rt.extension.targetPlatform(targetWindowsX86_64) {
jdkHome = jdkDownload(jdk_download_windows_x86_64)
}
rt.extension.targetPlatform(targetWindowsX86_64) {
jdkHome = jdkDownload(jdk_download_windows_x86_64)
}
configure(rt, jvmArgsWindows)
@@ -376,40 +507,20 @@ tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
*/
tasks.register('runtimeZipOthers', RuntimeZipTask) {rt ->
//Linux aarch64
if(file(jdk_linux_aarch64).exists()) {
rt.extension.targetPlatform(targetLinuxAarch64, jdk_linux_aarch64, [])
}
else {
rt.extension.targetPlatform(targetLinuxAarch64) {
jdkHome = jdkDownload(jdk_download_linux_aarch64)
}
rt.extension.targetPlatform(targetLinuxAarch64) {
jdkHome = jdkDownload(jdk_download_linux_aarch64)
}
//Linux x86-64
if(file(jdk_linux_x86_64).exists()) {
rt.extension.targetPlatform(targetLinuxX86_64, jdk_linux_x86_64, [])
}
else {
rt.extension.targetPlatform(targetLinuxX86_64) {
jdkHome = jdkDownload(jdk_download_linux_x86_64)
}
rt.extension.targetPlatform(targetLinuxX86_64) {
jdkHome = jdkDownload(jdk_download_linux_x86_64)
}
//OSX aarch64
if(file(jdk_osx_aarch64).exists()) {
rt.extension.targetPlatform(targetOsxAarch64, jdk_osx_aarch64, [])
}
else {
rt.extension.targetPlatform(targetOsxAarch64) {
jdkHome = jdkDownload(jdk_download_osx_aarch64)
}
rt.extension.targetPlatform(targetOsxAarch64) {
jdkHome = jdkDownload(jdk_download_osx_aarch64)
}
//OSX x86-64
if(file(jdk_osx_x86_64).exists()) {
rt.extension.targetPlatform(targetOsxX86_64, jdk_osx_x86_64, [])
}
else {
rt.extension.targetPlatform(targetOsxX86_64) {
jdkHome = jdkDownload(jdk_download_osx_x86_64)
}
rt.extension.targetPlatform(targetOsxX86_64) {
jdkHome = jdkDownload(jdk_download_osx_x86_64)
}
configure(rt, jvmArgsLinux)

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

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500

52
gradlew.bat vendored
View File

@@ -26,6 +26,33 @@
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

2
mise.toml Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
maven { url = 'https://jitpack.io' }
//jAirspy is published as a GitHub release, with the native libraries for all platforms in the jar
ivy {
@@ -82,5 +82,14 @@ dependencyResolutionManagement {
metadataSources { artifact() }
content { includeModule 'io.github.dsheirer', 'jbladerf' }
}
//radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api)
//are not pulled in transitively and are declared directly in build.gradle
ivy {
url = 'https://github.com/DSheirer/radio-reference-api/releases/download'
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
metadataSources { artifact() }
content { includeModule 'io.github.dsheirer', 'radio-reference-api' }
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,483 +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.audio.convert.thumbdv;
import com.fazecast.jSerialComm.SerialPort;
import io.github.dsheirer.audio.convert.thumbdv.message.AmbeMessage;
import io.github.dsheirer.audio.convert.thumbdv.message.AmbeMessageFactory;
import io.github.dsheirer.audio.convert.thumbdv.message.VocoderRate;
import io.github.dsheirer.audio.convert.thumbdv.message.request.AmbeRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.request.DecodeSpeechRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.request.EncodeSpeechRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.request.ResetRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.request.SetVocoderParametersRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.request.SetVocoderRequest;
import io.github.dsheirer.audio.convert.thumbdv.message.response.DecodeSpeechResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.ReadyResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetVocoderParameterResponse;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.util.ThreadPool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* Northwest Digital Radio (NWDR) ThumbDv dongle.
*
* Note: linux users must allow access to the serial port:
*
* sudo usermod -a -G uucp username
* sudo usermod -a -G dialout username
* sudo usermod -a -G lock username
* sudo usermod -a -G tty username
*/
public class ThumbDv implements AutoCloseable
{
private final static Logger mLog = LoggerFactory.getLogger(ThumbDv.class);
private static final String PORT_DESCRIPTION = "USB-to-Serial Port (ftdi_sio)";
private static final String PORT_DESCRIPTION_FRAGMENT = "USB Serial Port";
private static final int BAUD_RATE = 460800;
private static final byte PACKET_START = (byte) 0x61;
public enum AudioProtocol
{
DMR,
DSTAR,
NXDN,
P25_PHASE2,
}
private SerialPort mSerialPort;
private ScheduledFuture mSerialPortReaderHandle;
private ScheduledFuture mAudioDecodeProcessorHandle;
private LinkedTransferQueue<DecodeSpeechRequest> mDecodeSpeechRequests = new LinkedTransferQueue<>();
private AudioProtocol mAudioProtocol;
private Listener<float[]> mAudioBufferListener;
private boolean mStarted;
public ThumbDv(AudioProtocol audioProtocol, Listener<float[]> listener)
{
mAudioProtocol = audioProtocol;
mAudioBufferListener = listener;
}
/**
* Enqueues the audio code frame for decoding. Decoded PCM speech packet will be sent to the registered
* audio packet listener.
*
* @param codecFrame
*/
public void decode(byte[] codecFrame)
{
if(!mStarted || mSerialPort == null)
{
throw new IllegalStateException("Must invoke start() method and thumbdv serial device must be available");
}
mDecodeSpeechRequests.offer(new DecodeSpeechRequest(codecFrame));
}
public void close() throws IOException
{
if(mSerialPortReaderHandle != null)
{
mSerialPortReaderHandle.cancel(true);
mSerialPortReaderHandle = null;
}
if(mSerialPort != null)
{
mSerialPort.closePort();
}
}
public void start() throws IOException
{
if(mSerialPort == null)
{
SerialPort[] ports = SerialPort.getCommPorts();
for(SerialPort port : ports)
{
mLog.debug("Serial Port Name:" + port.getDescriptivePortName());
if(port.getDescriptivePortName().contentEquals(PORT_DESCRIPTION) ||
port.getDescriptivePortName().contains(PORT_DESCRIPTION_FRAGMENT))
{
mSerialPort = port;
mSerialPort.setBaudRate(BAUD_RATE);
mSerialPort.openPort(0, 5000, 10000);
if(mSerialPort.isOpen())
{
mLog.info("Resetting ThumbDv Device");
send(new ResetRequest());
mLog.info("Creating Serial Port Reader");
final Runnable r = new SerialPortReader(mSerialPort.getInputStream());
mLog.info("Starting Serial Port Reader");
mSerialPortReaderHandle = ThreadPool.SCHEDULED.scheduleAtFixedRate(r, 0,
5, TimeUnit.MILLISECONDS);
mStarted = true;
mLog.info("Startup complete - awaiting reset response");
}
else
{
mLog.warn("Could not open serial port: " + mSerialPort.getSystemPortName());
throw new IOException("Could not open serial port:" + mSerialPort.getSystemPortName());
}
break;
}
}
}
if(mSerialPort == null)
{
throw new IOException("ThumbDV serial port not found");
}
return;
}
public void send(byte[] message) throws IOException
{
if(mSerialPort == null)
{
throw new IOException("ThumbDv must be started before use");
}
int bytesWritten = mSerialPort.writeBytes(message, message.length);
if(bytesWritten < 0)
{
throw new IOException("Unable to write message:" + Arrays.toString(message));
}
else if(bytesWritten != message.length)
{
throw new IOException("Unable to write message:" + Arrays.toString(message) + " Bytes Written:" + bytesWritten);
}
}
/**
* Sends the AMBE request message
*
* @param request message
* @throws IOException if there is an error
*/
public void send(AmbeRequest request) throws IOException
{
send(request.getData());
}
private void receive(byte[] bytes)
{
AmbeMessage message = AmbeMessageFactory.getMessage(bytes);
if(message instanceof DecodeSpeechResponse && mAudioBufferListener != null)
{
float[] samples = ((DecodeSpeechResponse)message).getSamples();
mAudioBufferListener.receive(samples);
}
else if(message instanceof ReadyResponse && mAudioDecodeProcessorHandle == null)
{
if(mAudioDecodeProcessorHandle == null)
{
try
{
switch(mAudioProtocol)
{
case DSTAR:
send(new SetVocoderRequest(VocoderRate.RATE_33));
send(new SetVocoderParametersRequest(0x0130, 0x0763, 0x4000, 0x0000, 0x0000, 0x0048));
break;
case DMR:
case NXDN:
case P25_PHASE2:
send(new SetVocoderRequest(VocoderRate.RATE_33));
send(new SetVocoderParametersRequest(0x0431, 0x0754, 0x2400, 0x0000, 0x0000, 0x6F48));
break;
default:
throw new IllegalStateException("Unrecognized audio protocol:" + mAudioProtocol);
}
}
catch(IOException ioe)
{
mLog.error("Error setting audio protocol vocoder parameters");
}
mLog.info("ThumbDv Reset Complete");
}
else
{
mLog.info("ThumbDv Reset Detected");
}
}
else if(message instanceof SetVocoderParameterResponse)
{
if(((SetVocoderParameterResponse)message).isSuccessful())
{
mLog.info("Audio vocoder parameters configured for " + mAudioProtocol);
//Start the audio frame decode processor
mAudioDecodeProcessorHandle = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioDecodeProcessor(), 0,
10, TimeUnit.MILLISECONDS);
}
}
else if(message != null)
{
mLog.debug("RECEIVED:" + message.toString());
}
}
/**
* Logs the current settings of the serial port. Note: you must invoke start() before this method so that
* the serial port can be discovered and opened.
*/
public void logSerialPort()
{
if(mSerialPort != null)
{
StringBuilder sb = new StringBuilder();
sb.append("\nPort:\t\t\t").append(mSerialPort.getSystemPortName()).append("\n");
sb.append("Name:\t\t\t").append(mSerialPort.getDescriptivePortName()).append("\n");
sb.append("Baud Rate:\t\t").append(mSerialPort.getBaudRate()).append("\n");
sb.append("Data Bits:\t\t").append(mSerialPort.getNumDataBits()).append("\n");
sb.append("Parity Bits:\t").append(mSerialPort.getParity()).append("\n");
sb.append("Stop Bits:\t\t").append(mSerialPort.getNumStopBits()).append("\n");
sb.append("Flow Control:\t").append(mSerialPort.getFlowControlSettings()).append("\n");
sb.append("Is Open:\t\t").append(mSerialPort.isOpen()).append("\n");
mLog.info(sb.toString());
}
else
{
mLog.info("No serial port found");
}
}
/**
* Processes the audio frame decode queue
*/
public class AudioDecodeProcessor implements Runnable
{
@Override
public void run()
{
try
{
DecodeSpeechRequest request = mDecodeSpeechRequests.poll();
if(request != null)
{
try
{
send(request);
}
catch(IOException ioe)
{
mLog.error("Error decoding audio frame", ioe);
}
// try
// {
// //Force a 20ms sleep after submitting an audio frame to avoid overflowing the thumbdv
// Thread.sleep(20);
// }
// catch(InterruptedException ie)
// {
// //Do nothing
// }
}
}
catch(Throwable t)
{
mLog.error("Error", t);
}
}
}
public class SerialPortReader implements Runnable
{
private InputStream mInputStream;
public SerialPortReader(InputStream inputStream)
{
mInputStream = inputStream;
}
@Override
public void run()
{
try
{
if(mInputStream != null)
{
try
{
while(mInputStream.available() > 0)
{
byte[] buffer = new byte[400];
int bytesRead = mInputStream.readNBytes(buffer, 0, 1);
if(bytesRead == 1 && buffer[0] == PACKET_START)
{
while(mInputStream.available() < 2)
{
try
{
Thread.sleep(1);
}
catch(InterruptedException ie)
{
}
}
bytesRead = mInputStream.readNBytes(buffer, 1, 2);
if(bytesRead == 2)
{
int length = (0xFF & buffer[1]) << 8;
length += (0xFF & buffer[2]);
if(0 < length && length < 400)
{
length++; //Add a byte for the type byte
while(mInputStream.available() < length)
{
try
{
Thread.sleep(1);
}
catch(InterruptedException ie)
{
}
}
bytesRead = mInputStream.readNBytes(buffer, 3, length);
if(bytesRead == length)
{
receive(Arrays.copyOf(buffer, length + 3));
}
else
{
mLog.debug("Expected [" + length + "] but received [" + bytesRead + "] bytes - " + Arrays.toString(buffer));
}
}
else
{
mLog.error("Received packet with unexpected length: " + length);
//Don't process the buffer ... let the reader read and inspect 1 byte at a time
//to regain sync on the packet start byte
}
}
else
{
mLog.debug("Expected [2] but received [" + bytesRead + "] -- this shouldn't happen");
}
}
else
{
mLog.debug("Unrecognized byte: " + Arrays.toString(buffer));
}
}
}
catch(IOException ioe)
{
mLog.error("Error while reading ThumbDv serial port", ioe);
}
}
}
catch(Throwable t)
{
mLog.error("Error", t);
}
}
}
public static void main(String[] args)
{
mLog.debug("Starting");
String silence = "BEDDEA821EFD660C08";
String[] frames = {"0E46122323067C60F8", "0E469433C1067CF1BC", "0E46122B23067C60F8", "0E67162BE08874E2B4",
"0E46163BE1067CF1BC", "0E46122B23067C60F8", "0A06163BE00A5C303E", "0E46122B23067C60F8", "0E46163BE1847CE1FC",
"0E46122B23067C60F8"};
List<byte[]> frameData = new ArrayList<>();
for(String frame : frames)
{
byte[] bytes = new byte[frame.length() / 2];
for(int x = 0; x < frame.length(); x += 2)
{
String hex = frame.substring(x, x + 2);
bytes[x / 2] = (byte) (0xFF & Integer.parseInt(hex, 16));
}
frameData.add(bytes);
}
mLog.debug("Starting thumb dv thread(s)");
final Listener<float[]> listener = packet -> {
mLog.info("Got an audio packet!");
};
try(ThumbDv thumbDv = new ThumbDv(AudioProtocol.P25_PHASE2, listener))
{
thumbDv.start();
Thread.sleep(6000);
for(int x = 0; x < 20; x++)
{
thumbDv.send(new EncodeSpeechRequest(new short[160]));
Thread.sleep(20);
}
// for(byte[] frame : frameData)
// {
// thumbDv.decode(frame);
// }
while(true);
}
catch(IOException ioe)
{
mLog.error("Error", ioe);
}
catch(InterruptedException e)
{
e.printStackTrace();
}
}
}

View File

@@ -1,44 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message;
/**
* AMBE-3000R Message
*/
public abstract class AmbeMessage
{
/**
* Converts the byte array to a hexadecimal string array
*/
public static String toHex(byte[] data)
{
StringBuilder sb = new StringBuilder();
for(byte b: data)
{
sb.append(String.format("%02X", b));
}
return sb.toString();
}
}

View File

@@ -1,95 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message;
import io.github.dsheirer.audio.convert.thumbdv.message.response.DecodeSpeechResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.EncodeSpeechResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.GetConfigResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.InitializeCodecResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.ProductIdResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.ReadyResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetChannelFormatResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetChannelResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetPacketModeResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetSpeechFormatResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetVocoderParameterResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetVocoderResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.UnknownResponse;
import io.github.dsheirer.audio.convert.thumbdv.message.response.VersionResponse;
public class AmbeMessageFactory
{
private static final byte CONTROL_PACKET = (byte)0x00;
private static final byte CHANNEL_PACKET = (byte)0x01;
private static final byte SPEECH_PACKET = (byte)0x02;
private static final int INDEX_PACKET_TYPE = 3;
private static final int INDEX_CONTROL_PACKET_TYPE = 4;
public static AmbeMessage getMessage(byte[] data)
{
if(data != null && data.length >= 5)
{
if(data[INDEX_PACKET_TYPE] == CONTROL_PACKET)
{
PacketField packetField = PacketField.fromValue(data[INDEX_CONTROL_PACKET_TYPE]);
switch(packetField)
{
case PKT_CHANNEL_0:
return new SetChannelResponse(data);
case PKT_CHANNEL_FORMAT:
return new SetChannelFormatResponse(data);
case PKT_CODEC_STOP:
return new SetPacketModeResponse(data);
case PKT_GET_CONFIG:
return new GetConfigResponse(data);
case PKT_INIT:
return new InitializeCodecResponse(data);
case PKT_PRODUCT_ID:
return new ProductIdResponse(data);
case PKT_RATE_PARAMETER:
return new SetVocoderParameterResponse(data);
case PKT_RATE_TABLE:
return new SetVocoderResponse(data);
case PKT_READY:
return new ReadyResponse(data);
case PKT_SPEECH_FORMAT:
return new SetSpeechFormatResponse(data);
case PKT_VERSION_STRING:
return new VersionResponse(data);
}
}
else if(data[INDEX_PACKET_TYPE] == CHANNEL_PACKET)
{
return new EncodeSpeechResponse(data);
}
else if(data[INDEX_PACKET_TYPE] == SPEECH_PACKET)
{
return new DecodeSpeechResponse(data);
}
}
return new UnknownResponse(data);
}
}

View File

@@ -1,47 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message;
/**
* AMBE-3000 Initialization Options
*/
public enum InitializeOption
{
ENCODER((byte)0x01),
DECODER((byte)0x02),
ENCODER_AND_DECODER((byte)0x03),
ECHO_CANCELLER((byte)0x04),
ENCODER_DECODER_ECHO_CANCELLER((byte)0x07);
private byte mCode;
InitializeOption(byte code)
{
mCode = code;
}
public byte getCode()
{
return mCode;
}
}

View File

@@ -1,160 +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.audio.convert.thumbdv.message;
/**
* AMBE-3000R Control Packet Field enumeration
*/
public enum PacketField
{
PKT_ENCODER_CMODE((byte)0x05),
PKT_DECODER_CMODE((byte)0x06),
PKT_RATE_TABLE((byte)0x09),
PKT_RATE_PARAMETER((byte)0x0A),
PKT_INIT((byte)0x0B),
PKT_LOW_POWER((byte)0x10),
PKT_CHANNEL_FORMAT((byte)0x15),
PKT_SPEECH_FORMAT((byte)0x16),
PKT_CODEC_START((byte)0x2A),
PKT_CODEC_STOP((byte)0x2B),
PKT_PRODUCT_ID((byte)0x30),
PKT_VERSION_STRING((byte)0x31),
PKT_COMPAND((byte)0x32),
PKT_RESET((byte)0x33),
PKT_RESET_TO_SOFTWARE_CONFIG((byte)0x34),
PKT_HALT((byte)0x35),
PKT_GET_CONFIG((byte)0x36),
PKT_READ_CONFIG((byte)0x37),
PKT_CODEC_CFG((byte)0x38),
PKT_READY((byte)0x39),
PKT_PARITY_MODE((byte)0x3F),
PKT_CHANNEL_0((byte)0x40),
PKT_WRITE_I2C((byte)0x44),
PKT_CLEAR_CODEC_RESET((byte)0x46),
PKT_SET_CODEC_RESET((byte)0x47),
PKT_DISCARD_CODEC_SAMPLES((byte)0x48),
PKT_DELAY_NUMBER_MICRO_SECONDS((byte)0x49),
PKT_DELAY_NUMBER_NANO_SECONDS((byte)0x4A),
PKT_GAIN((byte)0x4B),
PKT_RTS_THRESHOLD((byte)0x4E),
//Speech packet values
SPEECH_DATA((byte)0x00),
CHANNEL_DATA_HARD_SYMBOL((byte)0x01),
CMODE((byte)0x02),
SAMPLE_COUNT((byte)0x03),
TONE((byte)0x08),
CHANNEL_DATA_SOFT_SYMBOL((byte)0x17),
VOCODER((byte)0x40),
//Packet Types
PACKET_TYPE_CONTROL((byte)0x00),
PACKET_TYPE_ENCODE_SPEECH((byte)0x01),
PACKET_TYPE_DECODE_SPEECH((byte)0x02),
UNKNOWN((byte)0x00);
private byte mCode;
PacketField(byte code)
{
mCode = code;
}
/**
* Packet type byte code value
*/
public byte getCode()
{
return mCode;
}
/**
* Lookup a packet type from the code byte value
*/
public static PacketField fromValue(byte value)
{
switch(value)
{
case (byte)0x05:
return PKT_ENCODER_CMODE;
case (byte)0x06:
return PKT_DECODER_CMODE;
case (byte)0x09:
return PKT_RATE_TABLE;
case (byte)0x0A:
return PKT_RATE_PARAMETER;
case (byte)0x0B:
return PKT_INIT;
case (byte)0x10:
return PKT_LOW_POWER;
case (byte)0x15:
return PKT_CHANNEL_FORMAT;
case (byte)0x16:
return PKT_SPEECH_FORMAT;
case (byte)0x2A:
return PKT_CODEC_START;
case (byte)0x2B:
return PKT_CODEC_STOP;
case (byte)0x30:
return PKT_PRODUCT_ID;
case (byte)0x31:
return PKT_VERSION_STRING;
case (byte)0x32:
return PKT_COMPAND;
case (byte)0x33:
return PKT_RESET;
case (byte)0x34:
return PKT_RESET_TO_SOFTWARE_CONFIG;
case (byte)0x35:
return PKT_HALT;
case (byte)0x36:
return PKT_GET_CONFIG;
case (byte)0x37:
return PKT_READ_CONFIG;
case (byte)0x38:
return PKT_CODEC_CFG;
case (byte)0x39:
return PKT_READY;
case (byte)0x3F:
return PKT_PARITY_MODE;
case (byte)0x40:
return PKT_CHANNEL_0;
case (byte)0x44:
return PKT_WRITE_I2C;
case (byte)0x46:
return PKT_CLEAR_CODEC_RESET;
case (byte)0x47:
return PKT_SET_CODEC_RESET;
case (byte)0x48:
return PKT_DISCARD_CODEC_SAMPLES;
case (byte)0x49:
return PKT_DELAY_NUMBER_MICRO_SECONDS;
case (byte)0x4A:
return PKT_DELAY_NUMBER_NANO_SECONDS;
case (byte)0x4B:
return PKT_GAIN;
case (byte)0x4E:
return PKT_RTS_THRESHOLD;
default:
return UNKNOWN;
}
}
}

View File

@@ -1,129 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message;
public enum VocoderRate
{
//AMBE-1000 Standard Rates
RATE_0((byte)0x00, 2400,0),
RATE_1((byte)0x01, 3600,0),
RATE_2((byte)0x02, 3600,1200),
RATE_3((byte)0x03, 4800,0),
RATE_4((byte)0x04, 9600,0),
RATE_5((byte)0x05, 2350,50),
RATE_6((byte)0x06, 4850,4750),
RATE_7((byte)0x07, 4550,250),
RATE_8((byte)0x08, 3100,1700),
RATE_9((byte)0x09, 4400,2800),
RATE_10((byte)0x0A, 4150,2250),
RATE_11((byte)0x0B, 3350,250),
RATE_12((byte)0x0C, 7750,250),
RATE_13((byte)0x0D, 4650,3350),
RATE_14((byte)0x0E, 3750,250),
RATE_15((byte)0x0F, 4000,0),
//AMBE-2000 Standard Rates
RATE_16((byte)0x10, 3600,0),
RATE_17((byte)0x11, 4000,0),
RATE_18((byte)0x12, 4800,0),
RATE_19((byte)0x13, 6400,0),
RATE_20((byte)0x14, 8000,0),
RATE_21((byte)0x15, 9600,0),
RATE_22((byte)0x16, 2400,1600),
RATE_23((byte)0x17, 3600,1200),
RATE_24((byte)0x18, 4000,800),
RATE_25((byte)0x19, 2400,2400),
RATE_26((byte)0x1A, 4000,2400),
RATE_27((byte)0x1B, 4400,2800), //Is this compatible with APCO25 Phase 1?
RATE_28((byte)0x1C, 4000,4000),
RATE_29((byte)0x1D, 2400,7200),
RATE_30((byte)0x1E, 3600,6000),
RATE_31((byte)0x1F, 2000,0),
RATE_32((byte)0x20, 3600,2800),
//AMBE-3000 Standard Rates (AMBE+2)
RATE_33((byte)0x21, 2450,1150), //APCO25 HR (Phase 2), DMR, NXDN, DSTAR
RATE_34((byte)0x22, 2450,0),
RATE_35((byte)0x23, 2250,1150),
RATE_36((byte)0x24, 2250,0),
RATE_37((byte)0x25, 2400,0),
RATE_38((byte)0x26, 3000,0),
RATE_39((byte)0x27, 3600,0),
RATE_40((byte)0x28, 4000,0),
RATE_41((byte)0x29, 4400,0),
RATE_42((byte)0x2A, 4800,0),
RATE_43((byte)0x2B, 6400,0),
RATE_44((byte)0x2C, 7200,0),
RATE_45((byte)0x2D, 8000,0),
RATE_46((byte)0x2E, 9600,0),
RATE_47((byte)0x2F, 2450,250),
RATE_48((byte)0x30, 3350,250),
RATE_49((byte)0x31, 3750,250),
RATE_50((byte)0x32, 4550,250),
RATE_51((byte)0x33, 2450,1950),
RATE_52((byte)0x34, 2450,2350),
RATE_53((byte)0x35, 2450,3550),
RATE_54((byte)0x36, 2450,4750),
RATE_55((byte)0x37, 2600,1400),
RATE_56((byte)0x38, 3600,1200),
RATE_57((byte)0x39, 4000,800),
RATE_58((byte)0x3A, 4000,2400),
RATE_59((byte)0x3B, 4400,2800), //Is this compatible with APCO25 Phase 1?
RATE_60((byte)0x3C, 4000,4000),
RATE_61((byte)0x3D, 3600,6000);
private byte mCode;
private int mSpeechRate;
private int mFecRate;
VocoderRate(byte code, int speechRate, int fecRate)
{
mCode = code;
mSpeechRate = speechRate;
mFecRate = fecRate;
}
/**
* Byte code value for the vocoder rate
*/
public byte getCode()
{
return mCode;
}
/**
* Speech rate in bits per second (bps)
*/
public int getSpeechRate()
{
return mSpeechRate;
}
/**
* Forward Error Correction rate in bits per second (bps)
*/
public int getFecRate()
{
return mFecRate;
}
}

View File

@@ -1,67 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.AmbeMessage;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* AMBE-3000R Request Packet
*/
public abstract class AmbeRequest extends AmbeMessage
{
private static final byte PACKET_START_BYTE = (byte)0x61;
protected static final int PACKET_TYPE_INDEX = 3;
protected static final int PAYLOAD_START_INDEX = 4;
public abstract PacketField getType();
public abstract byte[] getData();
/**
* Creates a byte array of the specified length plus 4 packet header bytes with packet start, length and control
* bytes filled in.
*
* @param length of the message (not including the 4 byte packet header)
* @return byte array with packet header pre-filled.
*/
protected byte[] createMessage(int length, PacketField type)
{
byte[] data = new byte[length + 4];
data[0] = PACKET_START_BYTE;
data[1] = (byte)((length >> 8 & 0xFF));
data[2] = (byte)(length & 0xFF);
if(type == PacketField.PACKET_TYPE_ENCODE_SPEECH || type == PacketField.PACKET_TYPE_DECODE_SPEECH)
{
data[PACKET_TYPE_INDEX] = type.getCode();
}
else
{
data[PACKET_TYPE_INDEX] = PacketField.PACKET_TYPE_CONTROL.getCode();
data[PAYLOAD_START_INDEX] = type.getCode();
}
return data;
}
}

View File

@@ -1,108 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import io.github.dsheirer.audio.convert.thumbdv.message.VocoderRate;
/**
* Decode speech request, used to request decode of an encoded audio frame.
*/
public class DecodeSpeechRequest extends AmbeRequest
{
private static final int CHANNEL_DATA_IDENTIFIER_INDEX = 4;
private static final byte SAMPLE_COUNT = (byte)(0xFF & 160); //8 kHz Audio sample count for 20ms frame
private byte[] mAudioFrame;
private VocoderRate mVocoderRate;
/**
* Constructs an audio frame decode request using the specified vocoder rate.
* @param audioFrame of encoded audio samples
* @param vocoderRate to use when decoding
*/
public DecodeSpeechRequest(byte[] audioFrame, VocoderRate vocoderRate)
{
mAudioFrame = audioFrame;
mVocoderRate = vocoderRate;
}
/**
* Constructs an audio frame decode request using the current vocoder rate.
* @param audioFrame of encoded audio samples
*/
public DecodeSpeechRequest(byte[] audioFrame)
{
this(audioFrame, null);
}
@Override
public PacketField getType()
{
return PacketField.PACKET_TYPE_ENCODE_SPEECH;
}
private boolean hasVocoderRate()
{
return mVocoderRate != null;
}
@Override
public byte[] getData()
{
if(hasVocoderRate())
{
int length = mAudioFrame.length + 9;
byte[] data = createMessage(length, getType());
int offset = CHANNEL_DATA_IDENTIFIER_INDEX;
data[offset++] = PacketField.VOCODER.getCode();
data[offset++] = mVocoderRate.getCode();
data[offset++] = PacketField.CHANNEL_DATA_HARD_SYMBOL.getCode();
data[offset++] = (byte)(0xFF & (mAudioFrame.length * 8));
System.arraycopy(mAudioFrame, 0, data, offset, mAudioFrame.length);
offset += mAudioFrame.length;
data[offset++] = PacketField.SAMPLE_COUNT.getCode();
data[offset++] = (byte)0xA0;
data[offset++] = (byte)0x02;
data[offset++] = (byte)0x00;
data[offset] = (byte)0x00;
return data;
}
else
{
int length = mAudioFrame.length + 2;
byte[] data = createMessage(length, getType());
int offset = CHANNEL_DATA_IDENTIFIER_INDEX;
data[offset++] = PacketField.CHANNEL_DATA_HARD_SYMBOL.getCode();
data[offset++] = (byte)(0xFF & (mAudioFrame.length * 8));
System.arraycopy(mAudioFrame, 0, data, offset, mAudioFrame.length);
return data;
}
}
}

View File

@@ -1,88 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
public class EncodeSpeechRequest extends AmbeRequest
{
private static final int CHANNEL_IDENTIFIER_INDEX = 4;
private static final int SPEECH_DATA_IDENTIFIER_INDEX = 5;
private static final int SAMPLE_COUNT_INDEX = 6;
private static final int SPEECH_DATA_START_INDEX = 7;
private short[] mSamples;
public EncodeSpeechRequest(short[] samples)
{
mSamples = samples;
}
public EncodeSpeechRequest(float[] samples)
{
mSamples = new short[samples.length];
for(int x = 0; x < samples.length; x++)
{
if(samples[x] > 1.0f)
{
mSamples[x] = Short.MAX_VALUE;
}
else if(samples[x] < -1.0f)
{
mSamples[x] = Short.MIN_VALUE;
}
else
{
mSamples[x] = (short)(samples[x] * Short.MAX_VALUE);
}
}
}
@Override
public PacketField getType()
{
return PacketField.PACKET_TYPE_DECODE_SPEECH;
}
@Override
public byte[] getData()
{
int length = (mSamples.length * 2) + 3;
byte[] data = createMessage(length, getType());
data[CHANNEL_IDENTIFIER_INDEX] = PacketField.PKT_CHANNEL_0.getCode();
data[SPEECH_DATA_IDENTIFIER_INDEX] = (byte)0x00;
data[SAMPLE_COUNT_INDEX] = (byte)(0xFF & mSamples.length);
int pointer = SPEECH_DATA_START_INDEX;
for(short sample: mSamples)
{
data[pointer++] = (byte)((sample >> 8 & 0xFF));
data[pointer++] = (byte)(sample & 0xFF);
}
return data;
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Get configuration request packet
*/
public class GetConfigRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_GET_CONFIG;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,54 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.InitializeOption;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Initialize CODEC request packet
*/
public class InitializeCodecRequest extends AmbeRequest
{
private static final int INITIALIZE_OPTION_INDEX = 5;
private InitializeOption mInitializeOption;
public InitializeCodecRequest(InitializeOption option)
{
mInitializeOption = option;
}
@Override
public PacketField getType()
{
return PacketField.PKT_INIT;
}
@Override
public byte[] getData()
{
byte[] data = createMessage(2, getType());
data[INITIALIZE_OPTION_INDEX] = mInitializeOption.getCode();
return data;
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Product ID request packet
*/
public class ProductIdRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_PRODUCT_ID;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Reset request packet
*/
public class ResetRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_RESET;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,48 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set channel format request packet
*/
public class SetChannelFormatRequest extends AmbeRequest
{
private static final int FORMAT_INDEX = 5;
private static final byte FORMAT = (byte)0x00;
@Override
public PacketField getType()
{
return PacketField.PKT_CHANNEL_FORMAT;
}
@Override
public byte[] getData()
{
byte[] data = createMessage(2, getType());
data[FORMAT_INDEX] = FORMAT;
return data;
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set channel request packet
*/
public class SetChannelRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_CHANNEL_0;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Packet mode (versus CODEC mode) request packet
*/
public class SetPacketModeRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_CODEC_STOP;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,48 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set speech format request packet
*/
public class SetSpeechFormatRequest extends AmbeRequest
{
private static final int FORMAT_INDEX = 5;
private static final byte FORMAT = (byte)0x00;
@Override
public PacketField getType()
{
return PacketField.PKT_SPEECH_FORMAT;
}
@Override
public byte[] getData()
{
byte[] data = createMessage(2, getType());
data[FORMAT_INDEX] = FORMAT;
return data;
}
}

View File

@@ -1,74 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set Vocoder parameters request packet
*/
public class SetVocoderParametersRequest extends AmbeRequest
{
private int mWord0;
private int mWord1;
private int mWord2;
private int mWord3;
private int mWord4;
private int mWord5;
public SetVocoderParametersRequest(int word0, int word1, int word2, int word3, int word4, int word5)
{
mWord0 = word0;
mWord1 = word1;
mWord2 = word2;
mWord3 = word3;
mWord4 = word4;
mWord5 = word5;
}
@Override
public PacketField getType()
{
return PacketField.PKT_RATE_PARAMETER;
}
@Override
public byte[] getData()
{
byte[] data = createMessage(14, getType());
data[PAYLOAD_START_INDEX + 1] = (byte)(mWord0 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 2] = (byte)(mWord0 & 0xFF);
data[PAYLOAD_START_INDEX + 3] = (byte)(mWord1 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 4] = (byte)(mWord1 & 0xFF);
data[PAYLOAD_START_INDEX + 5] = (byte)(mWord2 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 6] = (byte)(mWord2 & 0xFF);
data[PAYLOAD_START_INDEX + 7] = (byte)(mWord3 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 8] = (byte)(mWord3 & 0xFF);
data[PAYLOAD_START_INDEX + 9] = (byte)(mWord4 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 10] = (byte)(mWord4 & 0xFF);
data[PAYLOAD_START_INDEX + 11] = (byte)(mWord5 >> 8 & 0xFF);
data[PAYLOAD_START_INDEX + 12] = (byte)(mWord5 & 0xFF);
return data;
}
}

View File

@@ -1,53 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import io.github.dsheirer.audio.convert.thumbdv.message.VocoderRate;
/**
* Set Vocoder Rate request packet
*/
public class SetVocoderRequest extends AmbeRequest
{
private VocoderRate mVocoderRate;
public SetVocoderRequest(VocoderRate rate)
{
mVocoderRate = rate;
}
@Override
public PacketField getType()
{
return PacketField.PKT_RATE_TABLE;
}
@Override
public byte[] getData()
{
byte[] data = createMessage(2, getType());
data[PAYLOAD_START_INDEX + 1] = mVocoderRate.getCode();
return data;
}
}

View File

@@ -1,43 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.request;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Version string request packet
*/
public class VersionRequest extends AmbeRequest
{
@Override
public PacketField getType()
{
return PacketField.PKT_VERSION_STRING;
}
@Override
public byte[] getData()
{
return createMessage(1, getType());
}
}

View File

@@ -1,60 +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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.AmbeMessage;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import java.util.Arrays;
/**
* AMBE-3000R Response Packet
*/
public abstract class AmbeResponse extends AmbeMessage
{
protected static final int PAYLOAD_START_INDEX = 5;
private byte[] mMessage;
protected AmbeResponse(byte[] message)
{
mMessage = message;
}
/**
* Control packet type
*/
public abstract PacketField getType();
/**
* Received message bytes
*/
protected byte[] getMessage()
{
return mMessage;
}
/**
* Payload of the packet (does not include the packet header)
*/
protected byte[] getPayload()
{
return Arrays.copyOfRange(getMessage(), PAYLOAD_START_INDEX, getMessage().length);
}
}

View File

@@ -1,59 +0,0 @@
/*******************************************************************************
* sdr-trunk
* 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import io.github.dsheirer.sample.ConversionUtils;
import java.util.Arrays;
/**
* Decode speech response
*/
public class DecodeSpeechResponse extends AmbeResponse
{
public DecodeSpeechResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PACKET_TYPE_DECODE_SPEECH;
}
/**
* Payload of the packet (does not include the packet header)
*/
protected byte[] getPayload()
{
return Arrays.copyOfRange(getMessage(), PAYLOAD_START_INDEX + 1, getMessage().length);
}
public float[] getSamples()
{
return ConversionUtils.convertFromSigned16BitSamples(getPayload());
}
@Override
public String toString()
{
return "DECODED SPEECH: SAMPLE COUNT:" + getSamples().length + " " + toHex(getPayload());
}
}

View File

@@ -1,59 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import java.util.Arrays;
/**
* Encode speech response
*/
public class EncodeSpeechResponse extends AmbeResponse
{
public EncodeSpeechResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PACKET_TYPE_ENCODE_SPEECH;
}
/**
* Payload of the packet (does not include the packet header)
*/
protected byte[] getPayload()
{
return Arrays.copyOfRange(getMessage(), PAYLOAD_START_INDEX + 1, getMessage().length);
}
@Override
public String toString()
{
return "ENCODED SPEECH: " + toHex(getPayload()) + " " + Arrays.toString(getMessage());
}
}

View File

@@ -1,49 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Get configuration response
*/
public class GetConfigResponse extends AmbeResponse
{
public GetConfigResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_GET_CONFIG;
}
@Override
public String toString()
{
return "CONFIGURATION: " + toHex(getPayload());
}
}

View File

@@ -1,58 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Initialize CODEC response
*/
public class InitializeCodecResponse extends AmbeResponse
{
public InitializeCodecResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_INIT;
}
/**
* Success / fail
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "INITIALIZE CODEC " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,56 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Product ID Response
*/
public class ProductIdResponse extends AmbeResponse
{
public ProductIdResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_PRODUCT_ID;
}
/**
* Product ID string
*/
public String getProductId()
{
return new String(getPayload()).trim();
}
@Override
public String toString()
{
return "PRODUCT ID:" + getProductId();
}
}

View File

@@ -1,50 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import java.util.Arrays;
/**
* Ready response
*/
public class ReadyResponse extends AmbeResponse
{
public ReadyResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_READY;
}
@Override
public String toString()
{
return "READY" + Arrays.toString(getMessage());
}
}

View File

@@ -1,57 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Reset response
*/
public class ResetResponse extends AmbeResponse
{
public ResetResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_RESET;
}
/**
* Success indicator
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "RESET " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,58 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set channel format response
*/
public class SetChannelFormatResponse extends AmbeResponse
{
public SetChannelFormatResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_CHANNEL_FORMAT;
}
/**
* Success or fail
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "SET CHANNEL FORMAT " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,57 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set Vocoder rate response
*/
public class SetChannelResponse extends AmbeResponse
{
public SetChannelResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_CHANNEL_0;
}
/**
* Success indicator
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "SET CHANNEL 0 " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,58 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set packet mode response
*/
public class SetPacketModeResponse extends AmbeResponse
{
public SetPacketModeResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_CODEC_STOP;
}
/**
* Success or fail
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "SET PACKET MODE " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,58 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set speech format response
*/
public class SetSpeechFormatResponse extends AmbeResponse
{
public SetSpeechFormatResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_SPEECH_FORMAT;
}
/**
* Success or fail
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "SET SPEECH FORMAT " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,65 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set Vocoder parameter response
*/
public class SetVocoderParameterResponse extends AmbeResponse
{
public SetVocoderParameterResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_RATE_PARAMETER;
}
/**
* Success indicator
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==3 && payload[0] == 0;
}
@Override
public String toString()
{
if(isSuccessful())
{
return "SET VOCODER RATE SUCCESSFUL";
}
else
{
return "SET VOCODER RATE **FAILED** - RESPONSE:" + toHex(getPayload());
}
}
}

View File

@@ -1,58 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Set Vocoder rate response
*/
public class SetVocoderResponse extends AmbeResponse
{
public SetVocoderResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_RATE_TABLE;
}
/**
* Success indicator
*/
public boolean isSuccessful()
{
byte[] payload = getPayload();
return payload != null && payload.length ==1 && payload[0] == 0;
}
@Override
public String toString()
{
return "SET VOCODER RATE " + (isSuccessful() ? "SUCCESSFUL" : "**FAILED**");
}
}

View File

@@ -1,50 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
import java.util.Arrays;
/**
* Unknown Response Message
*/
public class UnknownResponse extends AmbeResponse
{
public UnknownResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.UNKNOWN;
}
@Override
public String toString()
{
return "UNKNOWN MESSAGE:" + Arrays.toString(getMessage());
}
}

View File

@@ -1,56 +0,0 @@
/*
*
* * ******************************************************************************
* * 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.audio.convert.thumbdv.message.response;
import io.github.dsheirer.audio.convert.thumbdv.message.PacketField;
/**
* Version Response
*/
public class VersionResponse extends AmbeResponse
{
public VersionResponse(byte[] message)
{
super(message);
}
@Override
public PacketField getType()
{
return PacketField.PKT_VERSION_STRING;
}
/**
* Version string
*/
public String getVersion()
{
return new String(getPayload()).trim();
}
@Override
public String toString()
{
return "VERSION:" + getVersion();
}
}

View File

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

View File

@@ -1,137 +0,0 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2017 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package io.github.dsheirer.channel.details;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import net.miginfocom.swing.MigLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.text.DefaultCaret;
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class ChannelDetailPanel extends JPanel implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private JLabel mSystemLabel;
private JLabel mSiteLabel;
private JLabel mNameLabel;
private JTextArea mDetailTextPane;
private ChannelProcessingManager mChannelProcessingManager;
private ProcessingChain mProcessingChain;
public ChannelDetailPanel(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
init();
}
private void init()
{
setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[]0[grow,fill]"));
JPanel buttonPanel = new JPanel();
buttonPanel.setLayout(new MigLayout("insets 1 1 1 1", "[][grow,fill][][grow,fill][][grow,fill][]", ""));
buttonPanel.add(new JLabel("System:"));
mSystemLabel = new JLabel(" ");
buttonPanel.add(mSystemLabel);
buttonPanel.add(new JLabel("Site:"));
mSiteLabel = new JLabel(" ");
buttonPanel.add(mSiteLabel);
buttonPanel.add(new JLabel("Channel Name:"));
mNameLabel = new JLabel(" ");
buttonPanel.add(mNameLabel);
JButton refreshButton = new JButton("Refresh");
refreshButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
receive(mProcessingChain);
}
});
buttonPanel.add(refreshButton);
add(buttonPanel, "wrap");
mDetailTextPane = new JTextArea(EMPTY_DETAILS);
DefaultCaret caret = (DefaultCaret)mDetailTextPane.getCaret();
caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
add(new JScrollPane(mDetailTextPane));
}
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
final String system = channel != null ? channel.getSystem() : null;
final String site = channel != null ? channel.getSite() : null;
final String name = channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null;
final String details;
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState : processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
details = sb.toString();
}
else
{
details = EMPTY_DETAILS;
}
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
mSystemLabel.setText(system);
mSiteLabel.setText(site);
mNameLabel.setText(name);
mDetailTextPane.setText(details);
}
});
}
}

View File

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

View File

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

View File

@@ -1,138 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.channel.metadata;
import com.jidesoft.swing.JideSplitPane;
import com.jidesoft.swing.JideTabbedPane;
import io.github.dsheirer.channel.details.ChannelDetailPanel;
import io.github.dsheirer.gui.channel.ChannelSpectrumPanel;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.module.decode.event.DecodeEventPanel;
import io.github.dsheirer.module.decode.event.MessageActivityPanel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
import net.miginfocom.swing.MigLayout;
import javax.swing.JPanel;
/**
* Swing panel for Now Playing channels table and channel details tab set.
*/
public class NowPlayingPanel extends JPanel
{
private final ChannelMetadataPanel mChannelMetadataPanel;
private final ChannelDetailPanel mChannelDetailPanel;
private final DecodeEventPanel mDecodeEventPanel;
private final MessageActivityPanel mMessageActivityPanel;
private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel;
private JideTabbedPane mTabbedPane;
private JideSplitPane mSplitPane;
private boolean mDetailTabsVisible;
/**
* GUI panel that combines the currently decoding channels metadata table and viewers for channel details,
* messages, events, and spectral view.
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
SettingsManager settingsManager, boolean detailTabsVisible)
{
mChannelDetailPanel = new ChannelDetailPanel(playlistManager.getChannelProcessingManager());
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
mDetailTabsVisible = detailTabsVisible;
init();
}
/**
* Change the visibility of the channel details tabs panel.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
//Only adjust if there is a change in state
if(visible ^ mDetailTabsVisible)
{
mDetailTabsVisible = visible;
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
else
{
getSplitPane().remove(getTabbedPane());
}
revalidate();
}
}
private JideTabbedPane getTabbedPane()
{
if(mTabbedPane == null)
{
mTabbedPane = new JideTabbedPane();
mTabbedPane.addTab("Details", mChannelDetailPanel);
mTabbedPane.addTab("Events", mDecodeEventPanel);
mTabbedPane.addTab("Messages", mMessageActivityPanel);
mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
mTabbedPane.setFont(this.getFont());
//Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
.indexOfComponent(mChannelSpectrumSquelchPanel)));
}
return mTabbedPane;
}
/**
* Split pane for channels table and channel details tabs.
*/
private JideSplitPane getSplitPane()
{
if(mSplitPane == null)
{
mSplitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT);
mSplitPane.setShowGripper(true);
}
return mSplitPane;
}
private void init()
{
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
getSplitPane().add(mChannelMetadataPanel);
if(mDetailTabsVisible)
{
getSplitPane().add(getTabbedPane());
}
add(getSplitPane());
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel);
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel);
}
}

View File

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

View File

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

View File

@@ -257,6 +257,20 @@ public abstract class C4FMSymbolProcessor
return true;
}
/**
* Maximum bit errors allowed in a detected sync pattern while acquiring (ie not synchronized), measured from the
* symbol decisions at the optimized symbol timing. Sync detections with more bit errors are rejected. Bit errors
* include symbol magnitude (ie +3 vs +1) errors, so this rejects noise where the symbol signs correlate with the
* sync pattern but the symbol magnitudes are inconsistent. Once synchronized, the limit isn't applied since the
* sync detection timing is known and rejecting a valid sync detection costs the burst. Default implementation
* imposes no limit.
* @return maximum sync pattern bit error count while acquiring.
*/
public int getSyncAcquisitionMaxBitErrors()
{
return Integer.MAX_VALUE;
}
/**
* Number of consecutive sync periods (capped at 3) where a sync was detected within the max message length.
*/
@@ -945,12 +959,14 @@ public abstract class C4FMSymbolProcessor
}
/**
* Indicates if this correction has a valid NID or the optimization (or detection) score is high quality.
* Indicates if the optimization score is high quality and, while acquiring, the sync pattern bit errors don't
* exceed the max.
* @return true if valid.
*/
public boolean isValid()
{
return mOptimizationScore > getSyncThresholdEqualized();
return mOptimizationScore > getSyncThresholdEqualized() &&
(mSynchronized || mCorrectedBitCount <= getSyncAcquisitionMaxBitErrors());
}
}
@@ -1297,7 +1313,7 @@ public abstract class C4FMSymbolProcessor
for(int x = 0; x < patternSymbols.length; x++)
{
if(bufferPointer < maxPointer)
if(bufferPointer >= 0 && bufferPointer < maxPointer)
{
softSymbol = interpolate(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], fractional) * gain + balance;
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.record.AudioRecordingManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.MapViewSetting;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
@@ -59,7 +60,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 +83,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 +156,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();
@@ -171,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
mSettingsManager = new SettingsManager();
//Older versions stored the map view in the settings file. Move it to the map preferences once.
MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView();
if(legacyMapView != null)
{
mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(),
legacyMapView.getLongitude(), legacyMapView.getZoom());
}
AliasModel aliasModel = new AliasModel();
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
@@ -219,10 +242,13 @@ public class SDRTrunk implements Listener<TunerEvent>
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
}
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
if(!headless)
{
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
}
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
mTunerManager.getDiscoveredTunerModel().addListener(this);
@@ -253,6 +279,11 @@ public class SDRTrunk implements Listener<TunerEvent>
{
updateTitle(tuner.getPreferredName());
}
if(!duplicateLibraries.isEmpty())
{
showDuplicateLibrariesDialog(duplicateLibraries);
}
}
if(calibrating && !GraphicsEnvironment.isHeadless())
@@ -622,14 +653,14 @@ public class SDRTrunk implements Listener<TunerEvent>
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
mLog.info("Stopping spectral display ...");
mSpectralPanel.dispose();
mJavaFxWindowManager.shutdown();
mLog.info("Stopping channels ...");
mPlaylistManager.getChannelProcessingManager().shutdown();
mAudioRecordingManager.stop();
mResourceMonitor.stop();
mLog.info("Stopping spectral display ...");
mSpectralPanel.clearTuner();
mLog.info("Stopping tuners ...");
mTunerManager.stop();
mLog.info("Shutdown complete.");
@@ -798,7 +829,7 @@ public class SDRTrunk implements Listener<TunerEvent>
updateTitle(null);
break;
case NOTIFICATION_SHUTTING_DOWN:
Tuner currentTuner = mSpectralPanel.getTuner();
Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null;
if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == null)
{
@@ -909,6 +940,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

@@ -1,546 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.channel;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.PrimaryDecoder;
import io.github.dsheirer.module.decode.am.AMDecoder;
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import io.github.dsheirer.spectrum.ComplexDftProcessor;
import io.github.dsheirer.spectrum.FrequencyOverlayPanel;
import io.github.dsheirer.spectrum.SpectrumPanel;
import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter;
import io.github.dsheirer.spectrum.converter.DFTResultsConverter;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.MouseEvent;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
import net.miginfocom.swing.MigLayout;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.SpinnerNumberModel;
import javax.swing.event.MouseInputAdapter;
/**
* Display for channel FFT and squelch details
*/
public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingChain>
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumPanel.class);
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
private static final String LABEL_PREFIX_TUNER = "Tuner:";
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
private static final String LABEL_PREFIX_DECODER = "Decoder:";
private final PlaylistManager mPlaylistManager;
private ProcessingChain mProcessingChain;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
private SpectrumPanel mSpectrumPanel;
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
private final JLabel mTunerPPMLabel;
private final JLabel mTunerCorrectionLabel;
private final JLabel mChannelCorrectionLabel;
private final JLabel mDecoderCorrectionLabel;
private final JLabel mDummyLabel = new JLabel(" ");
private boolean mPanelVisible = false;
private boolean mDftProcessing = false;
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
private final JFXPanel mNoiseSquelchPanel;
private final JFXPanel mSymbolPanel;
private JSplitPane mSplitPane;
private FeedbackDecoder mFeedbackDecoder;
/**
* Constructs an instance.
*/
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
mSignalPowerView = new SignalPowerView(mPlaylistManager);
//Register the three swappable right-pane components with the theme manager. Only one is
//attached to the split pane at a time, so the LAF-toggle walk of Window.getWindows() can
//miss the detached ones - they get refreshed explicitly via registerSwing.
ThemeManager.getInstance().registerSwing(mSignalPowerView);
setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]"));
JPanel fftPanel = new JPanel();
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
JPanel labelPanel = new JPanel();
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
mTunerPPMLabel.setEnabled(false);
labelPanel.add(mTunerPPMLabel);
labelPanel.add(new JLabel("Corrections (Hz) "));
mTunerCorrectionLabel = new JLabel(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setEnabled(false);
labelPanel.add(mTunerCorrectionLabel);
mChannelCorrectionLabel = new JLabel(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setEnabled(false);
labelPanel.add(mChannelCorrectionLabel);
mDecoderCorrectionLabel = new JLabel(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setEnabled(false);
labelPanel.add(mDecoderCorrectionLabel);
labelPanel.add(mDummyLabel);
mNoiseFloorSpinnerModel = new SpinnerNumberModel(18, 8, 36, 1);
mNoiseFloorSpinnerModel.addChangeListener(e -> {
Number number = mNoiseFloorSpinnerModel.getNumber();
mSpectrumPanel.setSampleSize(number.doubleValue());
});
JSpinner noiseFloorSpinner = new JSpinner(mNoiseFloorSpinnerModel);
labelPanel.add(new JLabel("Noise Floor:"));
labelPanel.add(noiseFloorSpinner);
JButton logIndexesButton = new JButton("Log Settings");
logIndexesButton.addActionListener(e -> {
if(mProcessingChain != null)
{
Source source = mProcessingChain.getSource();
if(source instanceof PolyphaseChannelSource pcs)
{
List<Integer> indexes = pcs.getOutputProcessorIndexes();
double sampleRate = pcs.getSampleRate();
long indexCenterFrequency = pcs.getIndexCenterFrequency();
long appliedFrequencyOffset = pcs.getFrequencyOffset();
long requestedCenterFrequency = pcs.getFrequency();
StringBuilder sb = new StringBuilder();
sb.append("Polyphase Channel - BW: ").append(FREQUENCY_FORMAT.format(sampleRate / 1E6d));
sb.append(" Center/Requested/Mixer: ").append(FREQUENCY_FORMAT.format(indexCenterFrequency / 1E6d));
sb.append("/").append(FREQUENCY_FORMAT.format(requestedCenterFrequency / 1E6d));
sb.append("/").append(FREQUENCY_FORMAT.format(appliedFrequencyOffset / 1E6d));
sb.append(" Polyphase Indexes: ").append(indexes);
sb.append(" Tuner SR:").append(FREQUENCY_FORMAT.format(pcs.getTunerSampleRate() / 1E6d));
sb.append(" CF:").append(FREQUENCY_FORMAT.format(pcs.getTunerCenterFrequency() / 1E6d));
LOGGER.info(sb.toString());
LOGGER.info("Output Processor: " + pcs.getStateDescription());
}
else if(source instanceof HalfBandTunerChannelSource<?> hbtcs)
{
StringBuilder sb = new StringBuilder();
sb.append("Heterodyne Channel - CF:").append(FREQUENCY_FORMAT.format(hbtcs.getFrequency() / 1E6d));
sb.append(" SR:").append(FREQUENCY_FORMAT.format(hbtcs.getSampleRate() / 1E6d));
sb.append(" Mixer:").append(FREQUENCY_FORMAT.format(hbtcs.getMixerFrequency() / 1E6d));
LOGGER.info(sb.toString());
}
else
{
LOGGER.info("Unsupported channel type: " + (source != null ? source.getClass() : " null"));
}
}
});
//This is a debug button to log the current settings to the app log.
// labelPanel.add(mLogIndexesButton);
fftPanel.add(labelPanel, "wrap");
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
mSpectrumPanel = new SpectrumPanel(settingsManager);
mSpectrumPanel.setSampleSize(18.0);
/**
* The layered pane holds the overlapping spectrum and channel panels
* and manages the sizing of each panel with the resize listener
*/
JLayeredPane layeredPanel = new JLayeredPane();
layeredPanel.addComponentListener(new ResizeListener());
/**
* Create a mouse adapter to handle mouse events over the spectrum
* and waterfall panels
*/
MouseEventProcessor mouser = new MouseEventProcessor();
mFrequencyOverlayPanel.addMouseListener(mouser);
mFrequencyOverlayPanel.addMouseMotionListener(mouser);
mFrequencyOverlayPanel.addMouseWheelListener(mouser);
//Add the spectrum and channel panels to the layered panel
layeredPanel.add(mSpectrumPanel, 0, 0);
layeredPanel.add(mFrequencyOverlayPanel, 1, 0);
fftPanel.add(layeredPanel);
mNoiseSquelchPanel = new JFXPanel();
mSymbolPanel = new JFXPanel();
ThemeManager.getInstance().registerSwing(mNoiseSquelchPanel);
ThemeManager.getInstance().registerSwing(mSymbolPanel);
//Spin noise squelch panel construction off onto the JavafX UI thread.
Platform.runLater(() -> {
Scene scene = new Scene(mNoiseSquelchView);
ThemeManager.getInstance().register(scene);
mNoiseSquelchPanel.setScene(scene);
Scene scene2 = new Scene(mSymbolView);
URL resource = getClass().getResource("/sdrtrunk_style.css");
if(resource != null)
{
scene2.getStylesheets().add(resource.toExternalForm());
}
else
{
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
}
ThemeManager.getInstance().register(scene2);
mSymbolPanel.setScene(scene2);
});
mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
mSplitPane.add(fftPanel, JSplitPane.LEFT);
mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT);
add(mSplitPane);
// mSplitPane.setDividerLocation(0.5);
mSampleStreamTapModule.setListener(mComplexDftProcessor);
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
mComplexDftProcessor.addConverter(DFTResultsConverter);
DFTResultsConverter.addListener(mSpectrumPanel);
mSpectrumPanel.clearSpectrum();
}
/**
* Pads the value to a fixed width and returns the previx and padded value.
* @param prefix of the label
* @param value to pad with spaces
* @return padded, prefixed label
*/
private String getPaddedLabel(String prefix, Number value)
{
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
}
/**
* Signals this panel to indicate if this panel is visible to turn on the FFT processor when the panel is visible
* and turn off the FFT processor when it's not.
*
* Note: this method is intended to be called by the Swing event thread to ensure that only a single thread is
* invoking either this method, or the receive() method, since there is no thread synchronization between these
* two methods and they each depend on stable access to the mPanelVisible variable.
*
* @param visible true to indicate that this panel is showing/visible.
*/
public void setPanelVisible(boolean visible)
{
mPanelVisible = visible;
updateFFTProcessing();
mNoiseSquelchView.setShowing(visible);
mSymbolView.setShowing(visible);
}
/**
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
*/
private void updateFFTProcessing()
{
if(mPanelVisible && mProcessingChain != null)
{
startDftProcessing();
}
else
{
stopDftProcessing();
}
}
/**
* Starts DFT processing
*/
private void startDftProcessing()
{
if(!mDftProcessing)
{
mDftProcessing = true;
mSampleStreamTapModule.setListener(mComplexDftProcessor);
mComplexDftProcessor.start();
}
}
/**
* Stops DFT processing
*/
private void stopDftProcessing()
{
if(mDftProcessing)
{
mSampleStreamTapModule.removeListener();
mComplexDftProcessor.stop();
mSpectrumPanel.clearSpectrum();
mDftProcessing = false;
}
}
private void broadcast(SourceEvent sourceEvent)
{
if(mProcessingChain != null)
{
mProcessingChain.broadcast(sourceEvent);
}
}
/**
* Resets controls when changing processing chain source. Note: this must be called on the Swing
* dispatch thread because it directly invokes swing components.
*/
private void reset()
{
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
mTunerPPMLabel.setEnabled(false);
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setEnabled(false);
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setEnabled(false);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setEnabled(false);
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
mFrequencyOverlayPanel.setChannelBandwidth(0);
}
/**
* Receive notifications of request to provide display of processing chain details.
*/
@Override
public void receive(ProcessingChain processingChain)
{
//Disconnect the previous processing chain.
if(mProcessingChain != null)
{
mNoiseSquelchView.setController(null);
mSignalPowerView.setProcessingChain(null);
mSymbolView.removeSymbolProvider();
mSymbolView.setProtocol("");
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
mProcessingChain.removeModule(mSampleStreamTapModule);
mFeedbackDecoder = null;
}
//Invoking reset - we're on the Swing dispatch thread here
reset();
mProcessingChain = processingChain;
if(mProcessingChain != null)
{
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
{
setRightComponent(mNoiseSquelchPanel);
mNoiseSquelchView.setController(nbfmDecoder);
}
else if(primaryDecoder instanceof AMDecoder)
{
setRightComponent(mSignalPowerView);
mSignalPowerView.setProcessingChain(mProcessingChain);
}
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
{
setRightComponent(mSymbolPanel);
mSymbolView.setSymbolProvider(feedbackDecoder);
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
mFeedbackDecoder = feedbackDecoder;
}
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
if(source instanceof TunerChannelSource tcs)
{
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
}
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
}
}
}
updateFFTProcessing();
}
/**
* Shows the component on the right side of the split pane.
* @param component to show.
*/
private void setRightComponent(Component component)
{
Component rightComponent = mSplitPane.getRightComponent();
if(rightComponent != component)
{
mSplitPane.remove(rightComponent);
mSplitPane.setRightComponent(component);
mSplitPane.setDividerLocation(0.4);
}
}
/**
* Processor for source event stream to capture power level and squelch related source events.
*/
private class SourceEventProcessor implements Listener<SourceEvent>
{
@Override
public void receive(SourceEvent sourceEvent)
{
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS)
{
if(sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
{
//Note: we flip the sign on the error measurement because the value represents the amount of offset the PLL
//has to apply to move the signal to center/baseband
EventQueue.invokeLater(() -> {
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
mTunerPPMLabel.setEnabled(true);
if(status.isAutoPPM())
{
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
}
else
{
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
}
mTunerCorrectionLabel.setEnabled(true);
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
mChannelCorrectionLabel.setEnabled(true);
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
mDecoderCorrectionLabel.setEnabled(true);
//We se the offset cursor line to negative so that it aligns with where the signal is located
//instead of the correction that's needed to bring the signal to zero.
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
});
}
}
//TODO: is this still needed?
mSignalPowerView.receive(sourceEvent);
}
}
/**
* Monitors the sizing of the layered pane and resizes the spectrum and
* channel panels whenever the layered pane is resized
*/
public class ResizeListener implements ComponentListener
{
@Override public void componentResized(ComponentEvent e)
{
Component c = e.getComponent();
mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
mFrequencyOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
}
@Override public void componentHidden(ComponentEvent arg0) {}
@Override public void componentMoved(ComponentEvent arg0) {}
@Override public void componentShown(ComponentEvent arg0) {}
}
/**
* Mouse event handler for the spectral display panel.
*/
public class MouseEventProcessor extends MouseInputAdapter
{
@Override public void mouseMoved(MouseEvent event)
{
update(event);
}
/**
* Updates the cursor display while the mouse is performing actions
*/
private void update(MouseEvent event)
{
mFrequencyOverlayPanel.setCursorLocation(event.getPoint());
}
@Override public void mouseEntered(MouseEvent e)
{
mFrequencyOverlayPanel.setCursorVisible(true);
}
@Override public void mouseExited(MouseEvent e)
{
mFrequencyOverlayPanel.setCursorVisible(false);
}
}
}

View File

@@ -0,0 +1,309 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.channel;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.gui.power.SignalPowerView;
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
import io.github.dsheirer.gui.symbol.SymbolView;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.FeedbackDecoder;
import io.github.dsheirer.module.decode.PrimaryDecoder;
import io.github.dsheirer.module.decode.am.AMDecoder;
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
import io.github.dsheirer.source.Source;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SplitPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Display for channel spectrum, frequency corrections and the decoder squelch, power or symbol view.
*
* Use this class on the JavaFX application thread.
*/
public class ChannelSpectrumView extends SplitPane implements Listener<ProcessingChain>
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumView.class);
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
private static final String LABEL_PREFIX_TUNER = "Tuner:";
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
private static final String LABEL_PREFIX_DECODER = "Decoder:";
private static final double DIVIDER_POSITION = 0.4;
private final PlaylistManager mPlaylistManager;
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
private final ChannelSpectralDisplay mChannelSpectralDisplay = new ChannelSpectralDisplay();
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
private final Label mTunerPPMLabel = new Label(LABEL_PREFIX_PPM);
private final Label mTunerCorrectionLabel = new Label(LABEL_PREFIX_TUNER);
private final Label mChannelCorrectionLabel = new Label(LABEL_PREFIX_CHANNEL);
private final Label mDecoderCorrectionLabel = new Label(LABEL_PREFIX_DECODER);
private final NoiseSquelchView mNoiseSquelchView;
private final SignalPowerView mSignalPowerView;
private final SymbolView mSymbolView = new SymbolView();
private ProcessingChain mProcessingChain;
private boolean mPanelVisible = false;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channel configurations
*/
public ChannelSpectrumView(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
mSignalPowerView = new SignalPowerView(mPlaylistManager);
URL resource = getClass().getResource("/sdrtrunk_style.css");
if(resource != null)
{
mSymbolView.getStylesheets().add(resource.toExternalForm());
}
else
{
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
}
//Use the same color theme as the channel spectral display
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
Region leftSpacer = new Region();
Region rightSpacer = new Region();
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
HBox labels = new HBox(10, leftSpacer, mTunerPPMLabel, new Label("Corrections (Hz) "), mTunerCorrectionLabel,
mChannelCorrectionLabel, mDecoderCorrectionLabel, rightSpacer);
labels.setAlignment(Pos.CENTER);
labels.setPadding(new Insets(2));
VBox.setVgrow(mChannelSpectralDisplay, Priority.ALWAYS);
VBox spectrumBox = new VBox(labels, mChannelSpectralDisplay);
spectrumBox.setMinWidth(0);
getItems().addAll(spectrumBox, mNoiseSquelchView);
reset();
}
/**
* Pads the value to a fixed width and returns the prefix and padded value.
*/
private static String getPaddedLabel(String prefix, Number value)
{
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
}
/**
* Signals this view to indicate if it is visible, to turn on the DFT processor when the view is visible and turn
* it off when it's not.
* @param visible true to indicate that this view is showing/visible.
*/
public void setPanelVisible(boolean visible)
{
mPanelVisible = visible;
updateFFTProcessing();
mNoiseSquelchView.setShowing(visible);
mSymbolView.setShowing(visible);
}
/**
* Turns on DFT processing when there is a processing chain and the user has this view selected and visible.
* Otherwise, turns off DFT processing.
*/
private void updateFFTProcessing()
{
boolean active = mPanelVisible && mProcessingChain != null;
if(active)
{
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
}
else
{
mSampleStreamTapModule.removeListener();
}
mChannelSpectralDisplay.setActive(active);
}
/**
* Resets controls when changing processing chain source.
*/
private void reset()
{
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
mTunerPPMLabel.setDisable(true);
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
mTunerCorrectionLabel.setDisable(true);
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
mChannelCorrectionLabel.setDisable(true);
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
mDecoderCorrectionLabel.setDisable(true);
mChannelSpectralDisplay.setChannel(0, 0, 0);
}
/**
* Shows the processing chain details. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
//Disconnect the previous processing chain.
if(mProcessingChain != null)
{
mNoiseSquelchView.setController(null);
mSignalPowerView.setProcessingChain(null);
mSymbolView.removeSymbolProvider();
mSymbolView.setProtocol("");
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
mProcessingChain.removeModule(mSampleStreamTapModule);
}
reset();
mProcessingChain = processingChain;
if(mProcessingChain != null)
{
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
{
setRightComponent(mNoiseSquelchView);
mNoiseSquelchView.setController(nbfmDecoder);
}
else if(primaryDecoder instanceof AMDecoder)
{
setRightComponent(mSignalPowerView);
mSignalPowerView.setProcessingChain(mProcessingChain);
}
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
{
setRightComponent(mSymbolView);
mSymbolView.setSymbolProvider(feedbackDecoder);
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
}
mProcessingChain.addModule(mSampleStreamTapModule);
Source source = mProcessingChain.getSource();
int bandwidth = 0;
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
if(channel != null)
{
List<TunerChannel> tunerChannels = channel.getTunerChannels();
if(!tunerChannels.isEmpty())
{
bandwidth = tunerChannels.getFirst().getBandwidth();
}
}
if(source instanceof TunerChannelSource tcs)
{
mChannelSpectralDisplay.setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
}
}
updateFFTProcessing();
}
/**
* Shows the node on the right side of the split pane.
*/
private void setRightComponent(Node node)
{
if(getItems().size() < 2 || getItems().get(1) != node)
{
if(getItems().size() > 1)
{
getItems().remove(1);
}
getItems().add(node);
setDividerPositions(DIVIDER_POSITION);
}
}
/**
* Processor for source event stream to capture power level and squelch related source events.
*/
private class SourceEventProcessor implements Listener<SourceEvent>
{
@Override
public void receive(SourceEvent sourceEvent)
{
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS &&
sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
{
Platform.runLater(() -> {
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
mTunerPPMLabel.setDisable(false);
if(status.isAutoPPM())
{
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
}
else
{
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
}
mTunerCorrectionLabel.setDisable(false);
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
mChannelCorrectionLabel.setDisable(false);
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
mDecoderCorrectionLabel.setDisable(false);
});
//The offset cursor line is negative so that it aligns with where the signal is located instead of the
//correction that's needed to bring the signal to zero.
mChannelSpectralDisplay.setCarrierOffset(-status.getDecoderCorrection());
}
mSignalPowerView.receive(sourceEvent);
}
}
}

View File

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

View File

@@ -0,0 +1,64 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import com.google.common.base.Joiner;
import io.github.dsheirer.alias.Alias;
import java.util.List;
/**
* Alias table cell value: the alias names to show, with the color and icon of the alias that styles the cell.
* @param text of the alias names
* @param color of the styling alias as a 32-bit RGB integer
* @param iconName of the styling alias, or null
*/
public record AliasDisplay(String text, int color, String iconName) implements Comparable<AliasDisplay>
{
/**
* Creates the display for the aliases, styled by the first alias.
* @param aliases to display
* @return display or null when there are no aliases.
*/
public static AliasDisplay of(List<Alias> aliases)
{
if(aliases == null || aliases.isEmpty())
{
return null;
}
return of(Joiner.on(", ").skipNulls().join(aliases), aliases.getFirst());
}
/**
* Creates the display for the text, styled by the alias
* @param text to display
* @param alias to style the display
* @return display
*/
public static AliasDisplay of(String text, Alias alias)
{
return new AliasDisplay(text, alias.getColor(), alias.getIconName());
}
@Override
public int compareTo(AliasDisplay other)
{
return String.CASE_INSENSITIVE_ORDER.compare(text, other.text);
}
}

View File

@@ -0,0 +1,120 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.channel.state.DecoderState;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.sample.Listener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Activity summary details for the selected channel.
*/
public class ChannelDetailView extends VBox implements Listener<ProcessingChain>
{
private static final String EMPTY_DETAILS = "Please select a channel to view details";
private final ChannelProcessingManager mChannelProcessingManager;
private final Label mSystemLabel = new Label();
private final Label mSiteLabel = new Label();
private final Label mNameLabel = new Label();
private final TextArea mDetailTextArea = new TextArea(EMPTY_DETAILS);
private ProcessingChain mProcessingChain;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param channelProcessingManager for the channel of the processing chain
*/
public ChannelDetailView(ChannelProcessingManager channelProcessingManager)
{
mChannelProcessingManager = channelProcessingManager;
Button refreshButton = new Button("Refresh");
refreshButton.setTooltip(new Tooltip("Update the channel details"));
refreshButton.setOnAction(event -> refresh());
HBox labels = new HBox(5, new Label("System:"), grow(mSystemLabel), new Label("Site:"), grow(mSiteLabel),
new Label("Channel Name:"), grow(mNameLabel), refreshButton);
labels.setAlignment(Pos.CENTER_LEFT);
labels.setPadding(new Insets(3, 2, 3, 2));
mDetailTextArea.setEditable(false);
VBox.setVgrow(mDetailTextArea, Priority.ALWAYS);
getChildren().addAll(labels, mDetailTextArea);
}
/**
* Updates the details for the currently selected channel. Call on the JavaFX application thread.
*/
public void refresh()
{
receive(mProcessingChain);
}
private static Label grow(Label label)
{
label.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(label, Priority.ALWAYS);
return label;
}
/**
* Shows the details for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
mProcessingChain = processingChain;
Channel channel = mChannelProcessingManager.getChannel(processingChain);
mSystemLabel.setText(channel != null ? channel.getSystem() : null);
mSiteLabel.setText(channel != null ? channel.getSite() : null);
mNameLabel.setText(channel != null ?
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null);
if(processingChain != null)
{
StringBuilder sb = new StringBuilder();
for(DecoderState decoderState: processingChain.getDecoderStates())
{
sb.append(decoderState.getActivitySummary());
}
//Keep the scroll position, so that a refresh doesn't move the user back to the top
double scrollTop = mDetailTextArea.getScrollTop();
mDetailTextArea.setText(sb.toString());
mDetailTextArea.setScrollTop(scrollTop);
}
else
{
mDetailTextArea.setText(EMPTY_DETAILS);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.filter.FilterEditor;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import java.awt.EventQueue;
import java.util.function.IntConsumer;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
/**
* Toolbar for a history table: filter editor, clear, history size and column configuration controls.
*/
public class HistoryControls<T> extends HBox
{
/**
* Default number of items in the history
*/
public static final int DEFAULT_HISTORY_SIZE = 200;
private static final int MAXIMUM_HISTORY_SIZE = 2000;
private final String mFilterEditorTitle;
private final Button mFilterButton = new Button("Filters");
private final Button mClearButton = new Button("Clear");
private final Label mHistoryLabel = new Label("History:");
private final Slider mHistorySlider = new Slider(0, MAXIMUM_HISTORY_SIZE, DEFAULT_HISTORY_SIZE);
private final Label mHistoryValueLabel = new Label(String.valueOf(DEFAULT_HISTORY_SIZE));
//Swing filter editor, accessed on the Swing event thread
private FilterEditor<T> mFilterEditor;
private FilterSet<T> mFilterSet;
/**
* Constructs an instance
* @param filterEditorTitle for the filter editor window
* @param clear action for the clear button
* @param historySizeListener to receive history size changes
* @param tableColumnMonitor for the column configuration button
* @param columnsTitle for the column configuration editor
*/
public HistoryControls(String filterEditorTitle, Runnable clear, IntConsumer historySizeListener,
TableColumnMonitor<?> tableColumnMonitor, String columnsTitle)
{
super(5);
mFilterEditorTitle = filterEditorTitle;
setAlignment(Pos.CENTER_LEFT);
setPadding(new Insets(3, 2, 3, 2));
mFilterButton.setTooltip(new Tooltip("Edit filters"));
mFilterButton.setOnAction(event -> showFilterEditor());
mClearButton.setTooltip(new Tooltip("Clears the history"));
mClearButton.setOnAction(event -> clear.run());
mHistorySlider.setTooltip(new Tooltip("Adjust history size. Double-click to reset to default " +
DEFAULT_HISTORY_SIZE));
mHistorySlider.setBlockIncrement(25);
mHistorySlider.setPrefWidth(200);
mHistorySlider.valueProperty().addListener((observable, oldValue, newValue) -> {
int size = newValue.intValue();
mHistoryValueLabel.setText(String.valueOf(size));
historySizeListener.accept(size);
});
mHistorySlider.setOnMouseClicked(event -> {
if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2)
{
mHistorySlider.setValue(DEFAULT_HISTORY_SIZE);
}
});
mHistoryValueLabel.setMinWidth(40);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
getChildren().addAll(mFilterButton, mClearButton, mHistoryLabel, mHistorySlider, mHistoryValueLabel, spacer,
new ColumnConfigurationButton(tableColumnMonitor, columnsTitle));
setControlsEnabled(false);
}
/**
* Sets the enabled state of the history controls. The column configuration button is always enabled.
*/
public void setControlsEnabled(boolean enabled)
{
mFilterButton.setDisable(!enabled);
mClearButton.setDisable(!enabled);
mHistoryLabel.setDisable(!enabled);
mHistorySlider.setDisable(!enabled);
mHistoryValueLabel.setDisable(!enabled);
}
/**
* Updates the filter set for the filter editor.
* @param filterSet to edit, or null
*/
public void setFilterSet(FilterSet<T> filterSet)
{
mFilterSet = filterSet;
EventQueue.invokeLater(() -> {
if(mFilterEditor != null && filterSet != null)
{
mFilterEditor.updateFilterSet(filterSet);
}
});
}
/**
* Shows the filter editor. The filter editor is a Swing window.
*/
private void showFilterEditor()
{
FilterSet<T> filterSet = mFilterSet;
if(filterSet != null)
{
EventQueue.invokeLater(() -> {
if(mFilterEditor == null)
{
mFilterEditor = new FilterEditor<>(mFilterEditorTitle, null, filterSet);
}
mFilterEditor.setVisible(true);
mFilterEditor.toFront();
});
}
}
}

View File

@@ -0,0 +1,206 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.preference.swing.SwingPreference;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* Table of a history of items, newest first, with filter, clear, history size and column configuration controls.
*
* Use this class on the JavaFX application thread.
*
* @param <T> type of the history items
* @param <F> type of the filtered objects
*/
public abstract class HistoryTableView<T,F> extends VBox
{
private final ObservableList<Row<T>> mRows = FXCollections.observableArrayList();
private final FilteredList<Row<T>> mFilteredRows = new FilteredList<>(mRows);
private final Map<T,Row<T>> mRowMap = new HashMap<>();
private final TableView<Row<T>> mTableView = new TableView<>();
private final TableColumnMonitor<Row<T>> mTableColumnMonitor;
private final HistoryControls<F> mHistoryControls;
private int mHistorySize = HistoryControls.DEFAULT_HISTORY_SIZE;
/**
* Constructs an instance
* @param columns for the table
* @param tableKey for the stored column settings
* @param swingPreference with the legacy Swing column widths
* @param swingTableKey of the legacy Swing table
* @param filterEditorTitle for the filter editor window
* @param columnsTitle for the column configuration editor window
*/
protected HistoryTableView(List<TableColumn<Row<T>,?>> columns, String tableKey, SwingPreference swingPreference,
String swingTableKey, String filterEditorTitle, String columnsTitle)
{
mTableView.getColumns().addAll(columns);
SortedList<Row<T>> sortedRows = new SortedList<>(mFilteredRows);
sortedRows.comparatorProperty().bind(mTableView.comparatorProperty());
mTableView.setItems(sortedRows);
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
mTableView.setPlaceholder(new Label(""));
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, tableKey, swingPreference, swingTableKey);
mHistoryControls = new HistoryControls<>(filterEditorTitle, this::clear, this::setHistorySize,
mTableColumnMonitor, columnsTitle);
VBox.setVgrow(mTableView, Priority.ALWAYS);
getChildren().addAll(mHistoryControls, mTableView);
refilter();
}
/**
* Indicates if the item passes the current filters
*/
protected abstract boolean include(T item);
/**
* History controls toolbar
*/
protected HistoryControls<F> getHistoryControls()
{
return mHistoryControls;
}
/**
* Re-applies the filters to the items, after a filter change.
*/
protected void refilter()
{
mFilteredRows.setPredicate(row -> include(row.getItem()));
}
/**
* Adds the item to the top of the history, or updates the row of the item when it is already in the history.
* Removes the oldest items when the history is larger than the history size.
*/
protected void add(T item)
{
Row<T> row = mRowMap.get(item);
if(row != null)
{
//Replace the row with itself so that the filter re-checks the updated item
int index = mRows.indexOf(row);
if(index >= 0)
{
mRows.set(index, row);
}
row.refresh();
}
else
{
row = new Row<>(item);
mRowMap.put(item, row);
mRows.addFirst(row);
trim();
}
}
/**
* Replaces the history with the items, oldest first.
*/
protected void setItems(List<T> items)
{
clear();
for(T item: items)
{
add(item);
}
}
/**
* Removes all items from the history
*/
protected void clear()
{
mRows.clear();
mRowMap.clear();
}
/**
* Updates the cells of all rows, after a change to the display formats.
*/
protected void refreshAll()
{
mRows.forEach(Row::refresh);
}
private void setHistorySize(int historySize)
{
mHistorySize = historySize;
trim();
}
private void trim()
{
while(mRows.size() > mHistorySize)
{
mRowMap.remove(mRows.removeLast().getItem());
}
}
/**
* Table row item for a history item. The revision increments each time the item changes so that the row cells
* update.
*/
public static class Row<T>
{
private final T mItem;
private final IntegerProperty mRevision = new SimpleIntegerProperty();
Row(T item)
{
mItem = item;
}
public T getItem()
{
return mItem;
}
public IntegerProperty revisionProperty()
{
return mRevision;
}
void refresh()
{
mRevision.set(mRevision.get() + 1);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.filter.FilterSet;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.message.MessageHistory;
import io.github.dsheirer.message.StuffBitsMessage;
import io.github.dsheirer.module.ProcessingChain;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.event.MessageItem;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.TableColumn;
/**
* Table of the decoded messages for the selected channel.
*/
public class MessageActivityView extends HistoryTableView<MessageItem,IMessage> implements Listener<ProcessingChain>
{
private static final String TABLE_KEY = "now.playing.messages";
private static final String SWING_TABLE_KEY = "message.activity.panel";
private MessageHistory mCurrentMessageHistory;
private Listener<IMessage> mCurrentMessageListener;
private FilterSet<IMessage> mMessageFilterSet;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param userPreferences for the column settings
*/
public MessageActivityView(UserPreferences userPreferences)
{
super(createColumns(), TABLE_KEY, userPreferences.getSwingPreference(), SWING_TABLE_KEY,
"Message Filter Editor", "Message Columns");
}
@Override
protected boolean include(MessageItem item)
{
FilterSet<IMessage> filterSet = mMessageFilterSet;
if(filterSet != null && item.getMessage() != null)
{
return filterSet.canProcess(item.getMessage()) && filterSet.passes(item.getMessage());
}
return false;
}
/**
* Shows the messages for the processing chain. Call on the JavaFX application thread.
* @param processingChain to show, or null to clear the view
*/
@Override
public void receive(ProcessingChain processingChain)
{
if(mCurrentMessageHistory != null)
{
mCurrentMessageHistory.removeListener(mCurrentMessageListener);
}
//Unregister from changes made to the filter set
if(mMessageFilterSet != null)
{
mMessageFilterSet.register(null);
}
mCurrentMessageHistory = null;
mCurrentMessageListener = null;
if(processingChain != null)
{
MessageHistory history = processingChain.getMessageHistory();
FilterSet<IMessage> filterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
mMessageFilterSet = filterSet;
//Filter changes come from the Swing filter editor
filterSet.register(() -> Platform.runLater(() -> {
if(mMessageFilterSet == filterSet)
{
refilter();
}
}));
getHistoryControls().setFilterSet(filterSet);
//A listener for each history so that messages from the previous channel are ignored after a change
Listener<IMessage> listener = new Listener<>()
{
@Override
public void receive(IMessage message)
{
//Don't process tail bits or stuff bits message fragments
if(!(message instanceof StuffBitsMessage))
{
MessageItem item = new MessageItem(message);
Platform.runLater(() -> {
if(mCurrentMessageListener == this)
{
add(item);
}
});
}
}
};
mCurrentMessageHistory = history;
mCurrentMessageListener = listener;
List<MessageItem> items = new ArrayList<>();
for(IMessage message: history.getItems())
{
items.add(new MessageItem(message));
}
setItems(items);
refilter();
history.addListener(listener);
getHistoryControls().setControlsEnabled(true);
}
else
{
mMessageFilterSet = null;
clear();
getHistoryControls().setControlsEnabled(false);
}
}
private static List<TableColumn<Row<MessageItem>,?>> createColumns()
{
SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
List<TableColumn<Row<MessageItem>,?>> columns = new ArrayList<>();
columns.add(NowPlayingCells.column("time", "Time", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimestamp(timeFormat), null));
columns.add(NowPlayingCells.column("protocol", "Protocol", null, Pos.CENTER_LEFT,
row -> row.getItem().getProtocol(), null));
columns.add(NowPlayingCells.column("timeslot", "Timeslot", null, Pos.CENTER_LEFT,
row -> row.getItem().getTimeslot(), null));
columns.add(NowPlayingCells.column("message", "Message", null, Pos.CENTER_LEFT,
row -> row.getItem().getText(), null));
return columns;
}
}

View File

@@ -0,0 +1,166 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.table.TableColumnMonitor;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.Icon;
import io.github.dsheirer.icon.IconModel;
import java.util.function.Function;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.image.ImageView;
import javafx.util.Callback;
import javax.swing.UIManager;
/**
* Table column and cell helpers for the Now Playing tables.
*/
public final class NowPlayingCells
{
private static final String ALIGNMENT = "now.playing.cell.alignment";
private NowPlayingCells()
{
}
/**
* Creates a table column.
* @param id that identifies the column for storing the column settings. Don't change it, or users lose the
* stored order, visibility and width of the column.
* @param text for the column header
* @param configurationName for the column in the column configuration editor, or null to use the header text
* @param alignment of the cell contents
* @param value of the column for the row item. The value is recalculated each time the row revision changes.
* @param revision of the row item, or null when the row value doesn't change
*/
public static <S,T> TableColumn<S,T> column(String id, String text, String configurationName, Pos alignment,
Function<S,T> value, Function<S,ObservableValue<?>> revision)
{
TableColumn<S,T> column = new TableColumn<>(text);
column.setId(id);
if(configurationName != null)
{
TableColumnMonitor.setName(column, configurationName);
}
column.getProperties().put(ALIGNMENT, alignment);
column.setCellFactory(formatted(Object::toString));
if(revision == null)
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue())));
}
else
{
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue()),
revision.apply(param.getValue())));
}
return column;
}
/**
* Cell factory that shows the cell value formatted as text.
* @param formatter for non-null values. Can return null for an empty cell.
*/
public static <S,T> Callback<TableColumn<S,T>,TableCell<S,T>> formatted(Function<T,String> formatter)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(T item, boolean empty)
{
super.updateItem(item, empty);
setText(empty || item == null ? null : formatter.apply(item));
}
};
}
/**
* Cell factory for alias values that shows the alias names in the alias color with the alias icon.
* @param iconModel for the alias icons
*/
public static <S> Callback<TableColumn<S,AliasDisplay>,TableCell<S,AliasDisplay>> alias(IconModel iconModel)
{
return column -> new AlignedCell<>(column)
{
@Override
protected void updateItem(AliasDisplay item, boolean empty)
{
super.updateItem(item, empty);
if(empty || item == null)
{
setText(null);
setGraphic(null);
setStyle(null);
}
else
{
setText(item.text());
Icon icon = iconModel.getIcon(item.iconName());
setGraphic(icon != null && icon.getFxImage() != null ? new ImageView(icon.getFxImage()) : null);
java.awt.Color color = new java.awt.Color(item.color());
java.awt.Color background = UIManager.getColor("Table.background");
java.awt.Color readable = background != null ?
ThemeManager.getInstance().getReadableColor(color, background) : color;
String fill = toWebColor(readable.getRGB());
//An inline style, since the themed table row style otherwise replaces the text fill
setStyle("-fx-text-fill: " + fill + ";");
}
}
};
}
/**
* Formats the RGB value as a web color.
* @param rgb color value
* @return web color, e.g. #FF0000
*/
public static String toWebColor(int rgb)
{
return String.format("#%06X", rgb & 0xFFFFFF);
}
/**
* Table cell that aligns its contents with the alignment of its column.
*/
public static class AlignedCell<S,T> extends TableCell<S,T>
{
/**
* Constructs an instance
* @param column of the cell
*/
public AlignedCell(TableColumn<S,T> column)
{
if(column.getProperties().get(ALIGNMENT) instanceof Pos alignment)
{
setAlignment(alignment);
}
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.theme.ThemeManager;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.application.Platform;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
/**
* Swing panel that holds the JavaFX Now Playing view for the main application window. The JavaFX view is created
* on the JavaFX application thread after construction.
*/
public class NowPlayingPanel extends JFXPanel
{
private NowPlayingView mNowPlayingView;
private boolean mDetailTabsVisible;
/**
* Constructs an instance
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the channel detail tabs
*/
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
mDetailTabsVisible = detailTabsVisible;
Platform.setImplicitExit(false);
ThemeManager.getInstance().registerSwing(this);
Platform.runLater(() -> {
NowPlayingView view;
synchronized(NowPlayingPanel.this)
{
view = new NowPlayingView(playlistManager, iconModel, userPreferences, mDetailTabsVisible);
mNowPlayingView = view;
}
Scene scene = new Scene(view);
ThemeManager.getInstance().register(scene);
setScene(scene);
});
}
/**
* Changes the visibility of the channel details tabs. Can be called from any thread.
* @param visible true to show or false to hide.
*/
public synchronized void setDetailTabsVisible(boolean visible)
{
mDetailTabsVisible = visible;
if(mNowPlayingView != null)
{
NowPlayingView view = mNowPlayingView;
Platform.runLater(() -> view.setDetailTabsVisible(visible));
}
}
}

View File

@@ -0,0 +1,121 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.nowplaying;
import io.github.dsheirer.gui.channel.ChannelSpectrumView;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import javafx.geometry.Orientation;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
/**
* Now Playing view: the table of currently processing channels above the details, events, messages and channel tabs
* for the selected channel.
*
* Use this class on the JavaFX application thread.
*/
public class NowPlayingView extends SplitPane
{
private static final double DIVIDER_POSITION = 0.5;
private final ChannelMetadataView mChannelMetadataView;
private final ChannelDetailView mChannelDetailView;
private final DecodeEventView mDecodeEventView;
private final MessageActivityView mMessageActivityView;
private final ChannelSpectrumView mChannelSpectrumView;
private final TabPane mTabPane = new TabPane();
private boolean mDetailTabsVisible;
/**
* Constructs an instance. Construct on the JavaFX application thread.
* @param playlistManager for the channels
* @param iconModel for alias icons
* @param userPreferences for display formats and column settings
* @param detailTabsVisible to show the detail tabs
*/
public NowPlayingView(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
boolean detailTabsVisible)
{
setOrientation(Orientation.VERTICAL);
mChannelMetadataView = new ChannelMetadataView(playlistManager.getChannelProcessingManager(), iconModel, userPreferences);
mChannelDetailView = new ChannelDetailView(playlistManager.getChannelProcessingManager());
mDecodeEventView = new DecodeEventView(iconModel, userPreferences, playlistManager.getAliasModel());
mMessageActivityView = new MessageActivityView(userPreferences);
mChannelSpectrumView = new ChannelSpectrumView(playlistManager);
Tab detailsTab = new Tab("Details", mChannelDetailView);
Tab channelTab = new Tab("Channel", mChannelSpectrumView);
mTabPane.getTabs().addAll(detailsTab, new Tab("Events", mDecodeEventView),
new Tab("Messages", mMessageActivityView), channelTab);
mTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
//Turn the channel spectrum processing on only while the channel tab is selected
mTabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
mChannelSpectrumView.setPanelVisible(newTab == channelTab);
//Show current details each time the user opens the details tab
if(newTab == detailsTab)
{
mChannelDetailView.refresh();
}
});
mChannelMetadataView.addProcessingChainSelectionListener(mChannelDetailView);
mChannelMetadataView.addProcessingChainSelectionListener(mDecodeEventView);
mChannelMetadataView.addProcessingChainSelectionListener(mMessageActivityView);
mChannelMetadataView.addProcessingChainSelectionListener(mChannelSpectrumView);
getItems().add(mChannelMetadataView);
setDetailTabsVisible(detailTabsVisible);
}
/**
* Changes the visibility of the channel details tabs.
* @param visible true to show or false to hide.
*/
public void setDetailTabsVisible(boolean visible)
{
if(visible && !getItems().contains(mTabPane))
{
getItems().add(mTabPane);
setDividerPositions(DIVIDER_POSITION);
}
else if(!visible)
{
getItems().remove(mTabPane);
}
mDetailTabsVisible = visible;
//The channel tab is hidden with the detail tabs, so stop its spectrum processing
mChannelSpectrumView.setPanelVisible(mDetailTabsVisible &&
mTabPane.getSelectionModel().getSelectedIndex() == mTabPane.getTabs().size() - 1);
}
/**
* Prepares this view for disposal.
*/
public void dispose()
{
mChannelMetadataView.dispose();
mDecodeEventView.dispose();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,268 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM;
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
import io.github.dsheirer.rrapi.type.Site;
import io.github.dsheirer.rrapi.type.SiteFrequency;
import io.github.dsheirer.rrapi.type.System;
import io.github.dsheirer.rrapi.type.SystemInformation;
import io.github.dsheirer.source.config.SourceConfigTuner;
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Creates playlist channels from radio reference frequencies and trunked system sites.
*/
public final class RadioReferenceChannelFactory
{
private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceChannelFactory.class);
private RadioReferenceChannelFactory()
{
}
/**
* Creates a conventional channel for a radio reference frequency
* @param modeDecoderType of the frequency's radio reference mode
* @param frequency in Hertz
* @param system name for the channel
* @param site name for the channel
* @param name of the channel
* @return channel, or null when sdrtrunk has no decoder for the mode
*/
public static Channel conventional(ModeDecoderType modeDecoderType, long frequency, String system, String site,
String name)
{
if(modeDecoderType.hasDecoderType())
{
Channel channel = new Channel();
channel.setSystem(system);
channel.setSite(site);
channel.setName(name);
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequency);
channel.setSourceConfiguration(sourceConfigTuner);
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
{
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
}
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
}
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
{
if(modeDecoderType == ModeDecoderType.NXDN48)
{
nxdn.setTransmissionMode(TransmissionMode.M4800);
}
else if(modeDecoderType == ModeDecoderType.NXDN96)
{
nxdn.setTransmissionMode(TransmissionMode.M9600);
}
}
channel.setDecodeConfiguration(decodeConfiguration);
return channel;
}
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
return null;
}
/**
* Decoder type for a trunked system site's control channel. A P25 Phase II site with an FDMA control channel
* uses the Phase 1 decoder, which follows the Phase II traffic channels.
* @return decoder type, or null when sdrtrunk can't decode the site's control channel
*/
public static DecoderType getControlDecoderType(RadioReferenceDecoder decoder, System system, Site site)
{
//Motorola Type II control channels with P25 voice channels aren't supported
if(decoder.isHybridMotorolaP25(system))
{
return null;
}
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == DecoderType.P25_PHASE2 && (site == null || site.getTdmaControlChannel() == 0))
{
return DecoderType.P25_PHASE1;
}
return decoderType;
}
/**
* Control and alternate control channel frequencies of the site, control channels first
*/
public static List<Long> getControlFrequencies(Site site)
{
List<Long> frequencies = new ArrayList<>();
if(site != null && site.getSiteFrequencies() != null)
{
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isControl(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isAlternate(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
}
return frequencies;
}
/**
* Creates a channel for each of the site's repeaters on the frequencies, for systems without a control channel
* (LTR), where each repeater carries its own trunking data. The channels are named by logical channel number,
* like the playlist editor's site editor does.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site of the repeaters
* @param frequencies of the repeaters to create channels for
* @return channels, or an empty list when sdrtrunk can't decode the system
*/
public static List<Channel> repeaters(RadioReferenceDecoder decoder, System system,
SystemInformation systemInformation, Site site, List<Long> frequencies)
{
List<Channel> channels = new ArrayList<>();
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == null || site == null || site.getSiteFrequencies() == null)
{
return channels;
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(frequencies.contains(siteFrequency.getFrequencyHz()))
{
Channel channel = new Channel();
channel.setSystem(system.getName());
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : "Site " + site.getSiteNumber());
channel.setName("LCN " + siteFrequency.getLogicalChannelNumber());
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(siteFrequency.getFrequencyHz());
channel.setSourceConfiguration(sourceConfigTuner);
channels.add(channel);
}
}
return channels;
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site)
{
return trunkedSite(decoder, system, systemInformation, site, null);
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels, starting with the first frequency when it's one of them.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @param firstFrequency to list first, so the channel starts on it, or null for control channels first
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site, Long firstFrequency)
{
DecoderType decoderType = getControlDecoderType(decoder, system, site);
List<Long> frequencies = getControlFrequencies(site);
//A multi-frequency channel starts on its first frequency
if(firstFrequency != null && frequencies.remove(firstFrequency))
{
frequencies.add(0, firstFrequency);
}
if(decoderType == null || frequencies.isEmpty())
{
return null;
}
Channel channel = new Channel();
channel.setSystem(system.getName());
String siteName = "Site " + site.getSiteNumber();
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : siteName);
channel.setName(siteName);
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
if(frequencies.size() == 1)
{
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequencies.get(0));
channel.setSourceConfiguration(sourceConfigTuner);
}
else
{
SourceConfigTunerMultipleFrequency sourceConfig = new SourceConfigTunerMultipleFrequency();
for(long frequency: frequencies)
{
sourceConfig.addFrequency(frequency);
}
channel.setSourceConfiguration(sourceConfig);
}
return channel;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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