mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 01:57:39 -05:00
Compare commits
26 Commits
2566-P25P1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a10edf03 | ||
|
|
5bce541631 | ||
|
|
9641d2ef59 | ||
|
|
73fc85aa76 | ||
|
|
aad971db9c | ||
|
|
73d3238644 | ||
|
|
4626d56730 | ||
|
|
1a90eaf6c6 | ||
|
|
fbc54a38dd | ||
|
|
07b61be257 | ||
|
|
74dc32538f | ||
|
|
7fcf035337 | ||
|
|
a53f6e9d99 | ||
|
|
e7f4f655e5 | ||
|
|
000ac51ff0 | ||
|
|
23cee388de | ||
|
|
5c73fb318f | ||
|
|
066554393f | ||
|
|
303c0a98cc | ||
|
|
02c62d012f | ||
|
|
a4ec04a499 | ||
|
|
006e382b34 | ||
|
|
beb2593dd9 | ||
|
|
657cbf5c55 | ||
|
|
aff34fa5eb | ||
|
|
22cd8968cb |
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@@ -14,6 +14,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- 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
|
- name: Set up JDK 27
|
||||||
uses: actions/setup-java@v6
|
uses: actions/setup-java@v6
|
||||||
@@ -34,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Build Info file
|
- name: Create Build Info file
|
||||||
run: |
|
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
|
- name: Add build artifacts to nightly release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
|
|||||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Publishes a release when a version tag is pushed. The tag has to match
|
# Publishes a release when a version tag is pushed: v0.6.2 for a final release,
|
||||||
# projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
|
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the
|
||||||
# release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
|
# version from the tag (see build.gradle).
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -22,15 +22,6 @@ jobs:
|
|||||||
# Full history and tags, to find the previous release tag for the release notes
|
# Full history and tags, to find the previous release tag for the release notes
|
||||||
fetch-depth: 0
|
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
|
# 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.
|
# starts from the previous final release, so it covers every alpha and beta in between.
|
||||||
- id: previous
|
- id: previous
|
||||||
@@ -54,6 +45,17 @@ jobs:
|
|||||||
- name: Set up Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
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
|
- name: Build and test
|
||||||
run: ./gradlew clean build --console=plain
|
run: ./gradlew clean build --console=plain
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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
|
Releases are built and published automatically by the [Release](.github/workflows/release.yml) GitHub workflow
|
||||||
whenever a version tag is pushed to the repository.
|
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
|
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag
|
||||||
that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
|
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`,
|
||||||
2. Commit and push the change to `master`.
|
`v0.6.2-beta-1` or `v0.6.2`):
|
||||||
3. Create and push a tag that matches the version with a leading `v`:
|
|
||||||
```
|
```
|
||||||
git tag v0.6.2-beta-1
|
git tag v0.6.2-beta-1
|
||||||
git push origin 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:
|
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).
|
* 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
|
* 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
|
the PRs merged since the previous version tag), and attaches the six zip
|
||||||
|
|||||||
283
build.gradle
283
build.gradle
@@ -52,8 +52,44 @@ plugins {
|
|||||||
id 'org.beryx.runtime' version '2.0.1'
|
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 27 is required for this version of the Project Panama preview/incubator feature
|
||||||
java {
|
java {
|
||||||
@@ -95,66 +131,53 @@ test {
|
|||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
// JUnit Tests
|
// JUnit Tests
|
||||||
testImplementation(platform("org.junit:junit-bom:6.0.1"))
|
testImplementation(platform("org.junit:junit-bom:6.1.3"))
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'
|
testImplementation 'org.junit.jupiter:junit-jupiter-api'
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1")
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|
||||||
//Jitpack imports
|
//Jitpack imports
|
||||||
implementation 'com.github.dnault:libresample4j:master'
|
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'
|
implementation 'com.github.nwaldispuehl:java-lame:v3.98.4'
|
||||||
|
|
||||||
//mavenCentral/jcenter imports
|
//mavenCentral/jcenter imports
|
||||||
implementation 'ch.qos.logback:logback-classic:1.6.3'
|
implementation 'ch.qos.logback:logback-classic:1.6.6'
|
||||||
implementation 'ch.qos.logback:logback-core:1.5.38'
|
implementation 'ch.qos.logback:logback-core:1.6.6'
|
||||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.22.4'
|
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.23.0'
|
||||||
implementation 'com.fazecast:jSerialComm:2.11.4'
|
implementation 'com.formdev:flatlaf:3.8'
|
||||||
implementation 'com.formdev:flatlaf:3.7.2'
|
implementation 'com.formdev:flatlaf-intellij-themes:3.8' //Not imported - Theme.java loads these themes by class name
|
||||||
implementation 'com.formdev:flatlaf-intellij-themes:3.7.2'
|
|
||||||
implementation 'com.github.jiconfont:jiconfont-font_awesome:4.7.0.1'
|
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-javafx:1.0.0'
|
||||||
implementation 'com.github.jiconfont:jiconfont-swing:1.0.1'
|
implementation 'com.github.jiconfont:jiconfont-swing:1.0.1'
|
||||||
implementation 'com.github.wendykierp:JTransforms:3.2'
|
implementation('com.github.wendykierp:JTransforms:3.2') {
|
||||||
implementation 'com.google.code.gson:gson:2.14.0'
|
exclude group: 'junit' //JLargeArrays 1.7 declares junit as a compile dependency
|
||||||
implementation 'com.google.guava:guava:33.6.0-jre'
|
}
|
||||||
|
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.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 'com.mpatric:mp3agic:0.9.1'
|
||||||
implementation 'commons-io:commons-io:2.22.0'
|
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:jairspy:1.0.12' //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:jairspyhf:1.8.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
|
||||||
implementation 'io.github.dsheirer:jbladerf:2.6.0' //bladeRF tuners - GitHub release, see settings.gradle
|
implementation 'io.github.dsheirer:jhackrf:2026.01.3.1' //HackRF tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:jhackrf:2026.01.3' //HackRF tuners - GitHub release, see settings.gradle
|
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' //HydraSDR tuners - GitHub release, see settings.gradle
|
implementation 'io.github.dsheirer:jhydrasdr:1.1.4.1' //HydraSDR tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:jrtl:1.4.0.1' //RTL-SDR 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.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:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
|
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
|
||||||
implementation 'io.github.dsheirer:usb4java-native-libraries:1.3.3' //OSX & Windows aarch64 native libs
|
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
|
||||||
implementation('io.github.dsheirer:usb4java:1.3.5') { //Fork with Loader support for OSX Multi-Version
|
implementation 'org.apache.commons:commons-lang3:3.21.0'
|
||||||
//Excludes the javax.usb API and the duplicate original usb4java classes. The native libraries are listed below.
|
|
||||||
exclude group: 'org.usb4java', module: 'usb4java-javax'
|
|
||||||
}
|
|
||||||
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 'org.apache.commons:commons-math3:3.6.1'
|
implementation 'org.apache.commons:commons-math3:3.6.1'
|
||||||
implementation 'org.apache.commons:commons-csv:1.14.1'
|
implementation 'org.apache.commons:commons-csv:1.14.1'
|
||||||
implementation 'org.apache.mina:mina-core:2.2.9'
|
implementation 'org.apache.mina:mina-core:2.2.10'
|
||||||
implementation 'org.apache.mina:mina-http:2.2.9'
|
implementation 'org.apache.mina:mina-http:2.2.10'
|
||||||
implementation 'org.controlsfx:controlsfx:11.2.3'
|
implementation 'org.controlsfx:controlsfx:11.2.6'
|
||||||
implementation 'org.rauschig:jarchivelib:1.2.0'
|
implementation 'org.rauschig:jarchivelib:1.2.0'
|
||||||
implementation 'org.slf4j:slf4j-api:2.0.17'
|
implementation 'org.slf4j:slf4j-api:2.0.20'
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:darwin-x86-64' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:linux-aarch64' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:linux-arm' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:linux-x86' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:linux-x86-64' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:win32-x86' //usb4java native library
|
|
||||||
implementation 'org.usb4java:libusb4java:1.3.0:win32-x86-64' //usb4java native library
|
|
||||||
implementation 'pl.edu.icm:JLargeArrays:1.6'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def os = DefaultNativePlatform.currentOperatingSystem
|
def os = DefaultNativePlatform.currentOperatingSystem
|
||||||
@@ -215,6 +238,7 @@ jar {
|
|||||||
attributes (
|
attributes (
|
||||||
'Implementation-Title' : 'sdrtrunk project',
|
'Implementation-Title' : 'sdrtrunk project',
|
||||||
'Implementation-Version' : archiveVersion,
|
'Implementation-Version' : archiveVersion,
|
||||||
|
'Git-Describe' : gitDescribe ?: 'unknown',
|
||||||
'Created-By' : "Gradle ${gradle.gradleVersion}",
|
'Created-By' : "Gradle ${gradle.gradleVersion}",
|
||||||
'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
|
'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']}",
|
'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}",
|
||||||
@@ -245,18 +269,6 @@ String targetOsxX86_64 = 'osx-x86_64-v' + version
|
|||||||
String targetWindowsX86_64 = 'windows-x86_64-v' + version
|
String targetWindowsX86_64 = 'windows-x86_64-v' + version
|
||||||
String targetWindowsAarch64 = 'windows-aarch64-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
|
* Download URLs to download the JDK as part of the gradle build packaging process
|
||||||
*/
|
*/
|
||||||
@@ -285,19 +297,138 @@ def configure(RuntimeZipTask rt, List<String> jvmArgs) {
|
|||||||
//jdk.incubator.vector - needed for Project Panama foreign function and vector apis
|
//jdk.incubator.vector - needed for Project Panama foreign function and vector apis
|
||||||
//jdk.accessibility is used with assistive technologies like screen readers
|
//jdk.accessibility is used with assistive technologies like screen readers
|
||||||
//java.management for JVM resource monitoring
|
//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.
|
//Use only the modules listed above. The runtime plugin's module auto-detection uses a bundled ASM library that
|
||||||
rt.extension.additive.set(true)
|
//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
|
//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.
|
//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.imageDir=layout.buildDirectory.dir('image')
|
||||||
rt.extension.imageZip.set(file(layout.buildDirectory.dir('image').get().file('sdr-trunk.zip')))
|
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.
|
* Creates a release package for the current operating system.
|
||||||
*
|
*
|
||||||
@@ -354,23 +485,13 @@ tasks.register('runtimeZipCurrent', RuntimeZipTask) {rt ->
|
|||||||
*/
|
*/
|
||||||
tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
|
tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
|
||||||
//Windows aarch64
|
//Windows aarch64
|
||||||
if(file(jdk_windows_aarch64).exists()) {
|
|
||||||
rt.extension.targetPlatform(targetWindowsAarch64, jdk_windows_aarch64, [])
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rt.extension.targetPlatform(targetWindowsAarch64) {
|
rt.extension.targetPlatform(targetWindowsAarch64) {
|
||||||
jdkHome = jdkDownload(jdk_download_windows_aarch64)
|
jdkHome = jdkDownload(jdk_download_windows_aarch64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
//Windows x86-64
|
//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) {
|
rt.extension.targetPlatform(targetWindowsX86_64) {
|
||||||
jdkHome = jdkDownload(jdk_download_windows_x86_64)
|
jdkHome = jdkDownload(jdk_download_windows_x86_64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
configure(rt, jvmArgsWindows)
|
configure(rt, jvmArgsWindows)
|
||||||
}
|
}
|
||||||
@@ -386,41 +507,21 @@ tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
|
|||||||
*/
|
*/
|
||||||
tasks.register('runtimeZipOthers', RuntimeZipTask) {rt ->
|
tasks.register('runtimeZipOthers', RuntimeZipTask) {rt ->
|
||||||
//Linux aarch64
|
//Linux aarch64
|
||||||
if(file(jdk_linux_aarch64).exists()) {
|
|
||||||
rt.extension.targetPlatform(targetLinuxAarch64, jdk_linux_aarch64, [])
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rt.extension.targetPlatform(targetLinuxAarch64) {
|
rt.extension.targetPlatform(targetLinuxAarch64) {
|
||||||
jdkHome = jdkDownload(jdk_download_linux_aarch64)
|
jdkHome = jdkDownload(jdk_download_linux_aarch64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
//Linux x86-64
|
//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) {
|
rt.extension.targetPlatform(targetLinuxX86_64) {
|
||||||
jdkHome = jdkDownload(jdk_download_linux_x86_64)
|
jdkHome = jdkDownload(jdk_download_linux_x86_64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
//OSX aarch64
|
//OSX aarch64
|
||||||
if(file(jdk_osx_aarch64).exists()) {
|
|
||||||
rt.extension.targetPlatform(targetOsxAarch64, jdk_osx_aarch64, [])
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
rt.extension.targetPlatform(targetOsxAarch64) {
|
rt.extension.targetPlatform(targetOsxAarch64) {
|
||||||
jdkHome = jdkDownload(jdk_download_osx_aarch64)
|
jdkHome = jdkDownload(jdk_download_osx_aarch64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
//OSX x86-64
|
//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) {
|
rt.extension.targetPlatform(targetOsxX86_64) {
|
||||||
jdkHome = jdkDownload(jdk_download_osx_x86_64)
|
jdkHome = jdkDownload(jdk_download_osx_x86_64)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
configure(rt, jvmArgsLinux)
|
configure(rt, jvmArgsLinux)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,16 @@
|
|||||||
# *****************************************************************************
|
# *****************************************************************************
|
||||||
#
|
#
|
||||||
|
|
||||||
# Version naming conventions:
|
# The project version is no longer defined here. It is derived from the most recent 'v<version>' git tag (see
|
||||||
# Alpha: maj.min.patch-alpha-build
|
# build.gradle) and can be overridden with -PprojectVersion=<version>.
|
||||||
# Beta: maj.min.patch-beta-build
|
|
||||||
# Release: maj.min.patch
|
|
||||||
#
|
#
|
||||||
# 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.
|
# tag name, specifically the dashes inserted before and after the alpha/beta labels.
|
||||||
# See: https://github.com/DSheirer/sdrtrunk/issues/1651
|
# See: https://github.com/DSheirer/sdrtrunk/issues/1651
|
||||||
|
|
||||||
projectVersion=0.6.2-beta-1
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
52
gradlew.bat
vendored
52
gradlew.bat
vendored
@@ -26,6 +26,33 @@
|
|||||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
setlocal EnableExtensions
|
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
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation. 1>&2
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
echo. 1>&2
|
1>&2 echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation. 1>&2
|
1>&2 echo location of your Java installation.
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
|
goto exitWithErrorLevel
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
|
|||||||
@rem Execute gradlew
|
@rem Execute gradlew
|
||||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
@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
|
@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
|
:exitWithErrorLevel
|
||||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|||||||
1023
sdr-trunk.ipr
1023
sdr-trunk.ipr
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ dependencyResolutionManagement {
|
|||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
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
|
//jAirspy is published as a GitHub release, with the native libraries for all platforms in the jar
|
||||||
ivy {
|
ivy {
|
||||||
@@ -33,6 +33,15 @@ dependencyResolutionManagement {
|
|||||||
content { includeModule 'io.github.dsheirer', 'jrtl' }
|
content { includeModule 'io.github.dsheirer', 'jrtl' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//jlibusb is published as a GitHub release, with libusb for all platforms in the jar. Tuner libraries built on
|
||||||
|
//jlibusb (jAirspy, jAirspyHF, jBladeRF, jHackRF, jHydraSDR, jLimeSDR, jRTL) load their native library next to its libusb, so the process shares one copy.
|
||||||
|
ivy {
|
||||||
|
url = 'https://github.com/DSheirer/jlibusb/releases/download'
|
||||||
|
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||||
|
metadataSources { artifact() }
|
||||||
|
content { includeModule 'io.github.dsheirer', 'jlibusb' }
|
||||||
|
}
|
||||||
|
|
||||||
//jHackRF is published as a GitHub release, with the native libraries for all platforms in the jar
|
//jHackRF is published as a GitHub release, with the native libraries for all platforms in the jar
|
||||||
ivy {
|
ivy {
|
||||||
url = 'https://github.com/DSheirer/jHackRF/releases/download'
|
url = 'https://github.com/DSheirer/jHackRF/releases/download'
|
||||||
@@ -49,6 +58,14 @@ dependencyResolutionManagement {
|
|||||||
content { includeModule 'io.github.dsheirer', 'jhydrasdr' }
|
content { includeModule 'io.github.dsheirer', 'jhydrasdr' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//jLimeSDR is published as a GitHub release, with the native libraries for all platforms in the jar
|
||||||
|
ivy {
|
||||||
|
url = 'https://github.com/DSheirer/jLimeSDR/releases/download'
|
||||||
|
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||||
|
metadataSources { artifact() }
|
||||||
|
content { includeModule 'io.github.dsheirer', 'jlimesdr' }
|
||||||
|
}
|
||||||
|
|
||||||
//jsdrplay is published as a GitHub release. The jar does not contain the SDRplay API native library, which
|
//jsdrplay is published as a GitHub release. The jar does not contain the SDRplay API native library, which
|
||||||
//users install separately from sdrplay.com
|
//users install separately from sdrplay.com
|
||||||
ivy {
|
ivy {
|
||||||
@@ -65,5 +82,14 @@ dependencyResolutionManagement {
|
|||||||
metadataSources { artifact() }
|
metadataSources { artifact() }
|
||||||
content { includeModule 'io.github.dsheirer', 'jbladerf' }
|
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' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
|||||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
|
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
|
||||||
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
|
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
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.openmhz.OpenMHzConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
|
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
|
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
|
||||||
@@ -49,6 +50,7 @@ import java.net.SocketAddress;
|
|||||||
@JsonSubTypes({
|
@JsonSubTypes({
|
||||||
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
|
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
|
||||||
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
|
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
|
||||||
|
@JsonSubTypes.Type(value = ThinlineRadioConfiguration.class, name="ThinlineRadioConfiguration"),
|
||||||
@JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
|
@JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
|
||||||
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
|
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
|
||||||
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),
|
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),
|
||||||
|
|||||||
@@ -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.broadcastify.BroadcastifyFeedConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
|
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
|
||||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
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.rdioscanner.RdioScannerFeedConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
|
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
|
||||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
||||||
@@ -68,6 +69,7 @@ public class BroadcastFactory
|
|||||||
return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration,
|
return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration,
|
||||||
inputAudioFormat, mp3Setting, aliasModel);
|
inputAudioFormat, mp3Setting, aliasModel);
|
||||||
case RDIOSCANNER_CALL:
|
case RDIOSCANNER_CALL:
|
||||||
|
case THINLINE_RADIO:
|
||||||
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
|
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
|
||||||
inputAudioFormat, mp3Setting, aliasModel);
|
inputAudioFormat, mp3Setting, aliasModel);
|
||||||
case OPENMHZ:
|
case OPENMHZ:
|
||||||
@@ -113,6 +115,8 @@ public class BroadcastFactory
|
|||||||
return new BroadcastifyCallConfiguration(format);
|
return new BroadcastifyCallConfiguration(format);
|
||||||
case RDIOSCANNER_CALL:
|
case RDIOSCANNER_CALL:
|
||||||
return new RdioScannerConfiguration(format);
|
return new RdioScannerConfiguration(format);
|
||||||
|
case THINLINE_RADIO:
|
||||||
|
return new ThinlineRadioConfiguration(format);
|
||||||
case BROADCASTIFY:
|
case BROADCASTIFY:
|
||||||
return new BroadcastifyFeedConfiguration(format);
|
return new BroadcastifyFeedConfiguration(format);
|
||||||
case OPENMHZ:
|
case OPENMHZ:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public enum BroadcastServerType
|
|||||||
|
|
||||||
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
|
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
|
||||||
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
|
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
|
||||||
|
THINLINE_RADIO("Thinline Radio", "images/rdioscanner.png"),
|
||||||
OPENMHZ("OpenMHz", "images/openmhz.png"),
|
OPENMHZ("OpenMHz", "images/openmhz.png"),
|
||||||
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
|
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
|
||||||
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),
|
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
|||||||
}
|
}
|
||||||
else
|
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);
|
setBroadcastState(BroadcastState.ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +250,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
|||||||
}
|
}
|
||||||
catch(IOException e)
|
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)
|
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
|
//We get socket reset exceptions occasionally when the remote server doesn't
|
||||||
//fully read our request and immediately responds.
|
//fully read our request and immediately responds.
|
||||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
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.statusCode() + "] response [" +
|
||||||
fileResponse.body() + "]");
|
fileResponse.body() + "]");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
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.statusCode() + "] response [" +
|
||||||
fileResponse.body() + "]");
|
fileResponse.body() + "]");
|
||||||
}
|
}
|
||||||
@@ -321,7 +323,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
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.statusCode() + "] response [" +
|
||||||
fileResponse.body() + "]");
|
fileResponse.body() + "]");
|
||||||
}
|
}
|
||||||
@@ -334,7 +336,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
//Register an error for the file not found exception
|
//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() + "]");
|
audioRecording.getPath().toString() + "]");
|
||||||
incrementErrorAudioCount();
|
incrementErrorAudioCount();
|
||||||
broadcast(new BroadcastEvent(RdioScannerBroadcaster.this,
|
broadcast(new BroadcastEvent(RdioScannerBroadcaster.this,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
package io.github.dsheirer.audio.broadcast.rdioscanner;
|
package io.github.dsheirer.audio.broadcast.rdioscanner;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||||
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
||||||
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
|
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
|
||||||
@@ -125,6 +126,15 @@ public class RdioScannerConfiguration extends BroadcastConfiguration
|
|||||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
return BroadcastServerType.RDIOSCANNER_CALL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product name written to connection and upload logs.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public String getServiceName()
|
||||||
|
{
|
||||||
|
return "Rdio Scanner";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BroadcastConfiguration copyOf()
|
public BroadcastConfiguration copyOf()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ public class NowPlayingPanel extends JPanel
|
|||||||
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
|
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
|
||||||
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
|
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
|
||||||
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
|
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
|
||||||
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
|
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager);
|
||||||
mDetailTabsVisible = detailTabsVisible;
|
mDetailTabsVisible = detailTabsVisible;
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import io.github.dsheirer.channel.metadata.NowPlayingPanel;
|
|||||||
import io.github.dsheirer.eventbus.MyEventBus;
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
||||||
import io.github.dsheirer.icon.IconModel;
|
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.MapPanel;
|
||||||
import io.github.dsheirer.map.MapService;
|
import io.github.dsheirer.map.MapService;
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
@@ -63,7 +64,9 @@ public class ControllerPanel extends JPanel
|
|||||||
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
||||||
playlistManager.getAliasModel());
|
playlistManager.getAliasModel());
|
||||||
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
||||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager);
|
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
|
||||||
|
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||||
|
new RadioReferencePlaylistActions(playlistManager));
|
||||||
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ public class ChannelProcessingManager implements Listener<ChannelEvent>
|
|||||||
return !mProcessingChainsMap.isEmpty();
|
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
|
* Returns the current processing chain associated with the channel, or
|
||||||
* null if a processing chain is not currently setup for the channel
|
* null if a processing chain is not currently setup for the channel
|
||||||
|
|||||||
@@ -257,6 +257,20 @@ public abstract class C4FMSymbolProcessor
|
|||||||
return true;
|
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.
|
* 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.
|
* @return true if valid.
|
||||||
*/
|
*/
|
||||||
public boolean isValid()
|
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++)
|
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;
|
softSymbol = interpolate(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], fractional) * gain + balance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,29 @@ public class Golay24
|
|||||||
*/
|
*/
|
||||||
public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex)
|
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;
|
boolean parityError = message.cardinality() % 2 != 0;
|
||||||
|
|
||||||
int syndrome = getSyndrome(message, startIndex);
|
int syndrome = getSyndrome(message, startIndex);
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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.properties.SystemProperties;
|
||||||
import io.github.dsheirer.record.AudioRecordingManager;
|
import io.github.dsheirer.record.AudioRecordingManager;
|
||||||
import io.github.dsheirer.sample.Listener;
|
import io.github.dsheirer.sample.Listener;
|
||||||
|
import io.github.dsheirer.settings.MapViewSetting;
|
||||||
import io.github.dsheirer.settings.SettingsManager;
|
import io.github.dsheirer.settings.SettingsManager;
|
||||||
import io.github.dsheirer.source.tuner.Tuner;
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
import io.github.dsheirer.source.tuner.TunerEvent;
|
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.source.tuner.ui.TunerSpectralDisplayManager;
|
||||||
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
|
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
|
||||||
import io.github.dsheirer.spectrum.ShowTunerMenuItem;
|
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.ThreadPool;
|
||||||
import io.github.dsheirer.util.TimeStamp;
|
import io.github.dsheirer.util.TimeStamp;
|
||||||
import io.github.dsheirer.vector.calibrate.CalibrationManager;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.prefs.Preferences;
|
import java.util.prefs.Preferences;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
@@ -153,6 +156,17 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
mApplicationLog = new ApplicationLog(mUserPreferences);
|
mApplicationLog = new ApplicationLog(mUserPreferences);
|
||||||
mApplicationLog.start();
|
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);
|
mResourceMonitor = new ResourceMonitor(mUserPreferences);
|
||||||
|
|
||||||
ThreadPool.logSettings();
|
ThreadPool.logSettings();
|
||||||
@@ -171,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
|
|
||||||
mSettingsManager = new SettingsManager();
|
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();
|
AliasModel aliasModel = new AliasModel();
|
||||||
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
|
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
|
||||||
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
|
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
|
||||||
@@ -219,10 +242,13 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
|
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
if(!headless)
|
||||||
|
{
|
||||||
|
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||||
|
}
|
||||||
|
|
||||||
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
|
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
|
||||||
mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||||
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
|
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
|
||||||
mTunerManager.getDiscoveredTunerModel().addListener(this);
|
mTunerManager.getDiscoveredTunerModel().addListener(this);
|
||||||
|
|
||||||
@@ -253,6 +279,11 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
{
|
{
|
||||||
updateTitle(tuner.getPreferredName());
|
updateTitle(tuner.getPreferredName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!duplicateLibraries.isEmpty())
|
||||||
|
{
|
||||||
|
showDuplicateLibrariesDialog(duplicateLibraries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(calibrating && !GraphicsEnvironment.isHeadless())
|
if(calibrating && !GraphicsEnvironment.isHeadless())
|
||||||
@@ -622,14 +653,14 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
|
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
|
||||||
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
|
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
|
||||||
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
|
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
|
||||||
|
mLog.info("Stopping spectral display ...");
|
||||||
|
mSpectralPanel.dispose();
|
||||||
mJavaFxWindowManager.shutdown();
|
mJavaFxWindowManager.shutdown();
|
||||||
mLog.info("Stopping channels ...");
|
mLog.info("Stopping channels ...");
|
||||||
mPlaylistManager.getChannelProcessingManager().shutdown();
|
mPlaylistManager.getChannelProcessingManager().shutdown();
|
||||||
mAudioRecordingManager.stop();
|
mAudioRecordingManager.stop();
|
||||||
mResourceMonitor.stop();
|
mResourceMonitor.stop();
|
||||||
|
|
||||||
mLog.info("Stopping spectral display ...");
|
|
||||||
mSpectralPanel.clearTuner();
|
|
||||||
mLog.info("Stopping tuners ...");
|
mLog.info("Stopping tuners ...");
|
||||||
mTunerManager.stop();
|
mTunerManager.stop();
|
||||||
mLog.info("Shutdown complete.");
|
mLog.info("Shutdown complete.");
|
||||||
@@ -798,7 +829,7 @@ public class SDRTrunk implements Listener<TunerEvent>
|
|||||||
updateTitle(null);
|
updateTitle(null);
|
||||||
break;
|
break;
|
||||||
case NOTIFICATION_SHUTTING_DOWN:
|
case NOTIFICATION_SHUTTING_DOWN:
|
||||||
Tuner currentTuner = mSpectralPanel.getTuner();
|
Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null;
|
||||||
|
|
||||||
if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == 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.
|
* Launch the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ package io.github.dsheirer.gui.channel;
|
|||||||
import io.github.dsheirer.controller.channel.Channel;
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
|
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
|
||||||
import io.github.dsheirer.gui.power.SignalPowerView;
|
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.squelch.NoiseSquelchView;
|
||||||
import io.github.dsheirer.gui.symbol.SymbolView;
|
import io.github.dsheirer.gui.symbol.SymbolView;
|
||||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||||
@@ -33,23 +35,14 @@ import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
|
|||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import io.github.dsheirer.sample.Listener;
|
import io.github.dsheirer.sample.Listener;
|
||||||
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
|
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
|
||||||
import io.github.dsheirer.settings.SettingsManager;
|
|
||||||
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
|
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
|
||||||
import io.github.dsheirer.source.Source;
|
import io.github.dsheirer.source.Source;
|
||||||
import io.github.dsheirer.source.SourceEvent;
|
import io.github.dsheirer.source.SourceEvent;
|
||||||
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
|
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
|
||||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
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.Component;
|
||||||
import java.awt.EventQueue;
|
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.net.URL;
|
||||||
import java.text.DecimalFormat;
|
import java.text.DecimalFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -63,12 +56,8 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JLayeredPane;
|
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JSpinner;
|
|
||||||
import javax.swing.JSplitPane;
|
import javax.swing.JSplitPane;
|
||||||
import javax.swing.SpinnerNumberModel;
|
|
||||||
import javax.swing.event.MouseInputAdapter;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display for channel FFT and squelch details
|
* Display for channel FFT and squelch details
|
||||||
@@ -85,18 +74,20 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
private final PlaylistManager mPlaylistManager;
|
private final PlaylistManager mPlaylistManager;
|
||||||
private ProcessingChain mProcessingChain;
|
private ProcessingChain mProcessingChain;
|
||||||
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
||||||
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
|
private final JFXPanel mSpectralPanel = new JFXPanel();
|
||||||
private SpectrumPanel mSpectrumPanel;
|
//Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
|
||||||
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
|
private ChannelSpectralDisplay mChannelSpectralDisplay;
|
||||||
|
private long mChannelFrequency;
|
||||||
|
private int mChannelSampleRate;
|
||||||
|
private int mChannelBandwidth;
|
||||||
|
private Long mCarrierOffset;
|
||||||
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
||||||
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
|
|
||||||
private final JLabel mTunerPPMLabel;
|
private final JLabel mTunerPPMLabel;
|
||||||
private final JLabel mTunerCorrectionLabel;
|
private final JLabel mTunerCorrectionLabel;
|
||||||
private final JLabel mChannelCorrectionLabel;
|
private final JLabel mChannelCorrectionLabel;
|
||||||
private final JLabel mDecoderCorrectionLabel;
|
private final JLabel mDecoderCorrectionLabel;
|
||||||
private final JLabel mDummyLabel = new JLabel(" ");
|
private final JLabel mDummyLabel = new JLabel(" ");
|
||||||
private boolean mPanelVisible = false;
|
private boolean mPanelVisible = false;
|
||||||
private boolean mDftProcessing = false;
|
|
||||||
private final NoiseSquelchView mNoiseSquelchView;
|
private final NoiseSquelchView mNoiseSquelchView;
|
||||||
private final SignalPowerView mSignalPowerView;
|
private final SignalPowerView mSignalPowerView;
|
||||||
private final SymbolView mSymbolView = new SymbolView();
|
private final SymbolView mSymbolView = new SymbolView();
|
||||||
@@ -108,7 +99,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
/**
|
/**
|
||||||
* Constructs an instance.
|
* Constructs an instance.
|
||||||
*/
|
*/
|
||||||
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
|
public ChannelSpectrumPanel(PlaylistManager playlistManager)
|
||||||
{
|
{
|
||||||
mPlaylistManager = playlistManager;
|
mPlaylistManager = playlistManager;
|
||||||
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
||||||
@@ -124,7 +115,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
|
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
|
||||||
|
|
||||||
JPanel labelPanel = new JPanel();
|
JPanel labelPanel = new JPanel();
|
||||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
|
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
|
||||||
|
|
||||||
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
|
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
|
||||||
mTunerPPMLabel.setEnabled(false);
|
mTunerPPMLabel.setEnabled(false);
|
||||||
@@ -146,15 +137,6 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
|
|
||||||
labelPanel.add(mDummyLabel);
|
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");
|
JButton logIndexesButton = new JButton("Log Settings");
|
||||||
logIndexesButton.addActionListener(e -> {
|
logIndexesButton.addActionListener(e -> {
|
||||||
if(mProcessingChain != null)
|
if(mProcessingChain != null)
|
||||||
@@ -199,32 +181,16 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
|
|
||||||
fftPanel.add(labelPanel, "wrap");
|
fftPanel.add(labelPanel, "wrap");
|
||||||
|
|
||||||
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
|
fftPanel.add(mSpectralPanel);
|
||||||
mSpectrumPanel = new SpectrumPanel(settingsManager);
|
ThemeManager.getInstance().registerSwing(mSpectralPanel);
|
||||||
mSpectrumPanel.setSampleSize(18.0);
|
|
||||||
|
|
||||||
/**
|
Platform.runLater(() -> {
|
||||||
* The layered pane holds the overlapping spectrum and channel panels
|
ChannelSpectralDisplay display = new ChannelSpectralDisplay();
|
||||||
* and manages the sizing of each panel with the resize listener
|
Scene scene = new Scene(display);
|
||||||
*/
|
ThemeManager.getInstance().register(scene);
|
||||||
JLayeredPane layeredPanel = new JLayeredPane();
|
mSpectralPanel.setScene(scene);
|
||||||
layeredPanel.addComponentListener(new ResizeListener());
|
displayCreated(display);
|
||||||
|
});
|
||||||
/**
|
|
||||||
* 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();
|
mNoiseSquelchPanel = new JFXPanel();
|
||||||
mSymbolPanel = new JFXPanel();
|
mSymbolPanel = new JFXPanel();
|
||||||
@@ -249,6 +215,8 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
}
|
}
|
||||||
|
|
||||||
ThemeManager.getInstance().register(scene2);
|
ThemeManager.getInstance().register(scene2);
|
||||||
|
//Use the same color theme as the channel spectral display
|
||||||
|
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
|
||||||
mSymbolPanel.setScene(scene2);
|
mSymbolPanel.setScene(scene2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,11 +226,49 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
add(mSplitPane);
|
add(mSplitPane);
|
||||||
// mSplitPane.setDividerLocation(0.5);
|
// mSplitPane.setDividerLocation(0.5);
|
||||||
|
|
||||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
}
|
||||||
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
|
|
||||||
mComplexDftProcessor.addConverter(DFTResultsConverter);
|
/**
|
||||||
DFTResultsConverter.addListener(mSpectrumPanel);
|
* Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
|
||||||
mSpectrumPanel.clearSpectrum();
|
*/
|
||||||
|
private synchronized void displayCreated(ChannelSpectralDisplay display)
|
||||||
|
{
|
||||||
|
mChannelSpectralDisplay = display;
|
||||||
|
|
||||||
|
if(mChannelSampleRate > 0)
|
||||||
|
{
|
||||||
|
display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mCarrierOffset != null)
|
||||||
|
{
|
||||||
|
display.setCarrierOffset(mCarrierOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFFTProcessing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void setChannel(long frequency, int sampleRate, int bandwidth)
|
||||||
|
{
|
||||||
|
mChannelFrequency = frequency;
|
||||||
|
mChannelSampleRate = sampleRate;
|
||||||
|
mChannelBandwidth = bandwidth;
|
||||||
|
mCarrierOffset = null;
|
||||||
|
|
||||||
|
if(mChannelSpectralDisplay != null)
|
||||||
|
{
|
||||||
|
mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void setCarrierOffset(long offset)
|
||||||
|
{
|
||||||
|
mCarrierOffset = offset;
|
||||||
|
|
||||||
|
if(mChannelSpectralDisplay != null)
|
||||||
|
{
|
||||||
|
mChannelSpectralDisplay.setCarrierOffset(offset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,46 +301,28 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
|
* Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
|
||||||
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
|
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
|
||||||
*/
|
*/
|
||||||
private void updateFFTProcessing()
|
private synchronized void updateFFTProcessing()
|
||||||
{
|
{
|
||||||
if(mPanelVisible && mProcessingChain != null)
|
if(mChannelSpectralDisplay == null)
|
||||||
{
|
{
|
||||||
startDftProcessing();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean active = mPanelVisible && mProcessingChain != null;
|
||||||
|
|
||||||
|
if(active)
|
||||||
|
{
|
||||||
|
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
|
||||||
}
|
}
|
||||||
else
|
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();
|
mSampleStreamTapModule.removeListener();
|
||||||
mComplexDftProcessor.stop();
|
|
||||||
mSpectrumPanel.clearSpectrum();
|
|
||||||
mDftProcessing = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mChannelSpectralDisplay.setActive(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void broadcast(SourceEvent sourceEvent)
|
private void broadcast(SourceEvent sourceEvent)
|
||||||
@@ -359,10 +347,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
mChannelCorrectionLabel.setEnabled(false);
|
mChannelCorrectionLabel.setEnabled(false);
|
||||||
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
||||||
mDecoderCorrectionLabel.setEnabled(false);
|
mDecoderCorrectionLabel.setEnabled(false);
|
||||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
|
setChannel(0, 0, 0);
|
||||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
|
|
||||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
|
|
||||||
mFrequencyOverlayPanel.setChannelBandwidth(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,12 +399,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
mProcessingChain.addModule(mSampleStreamTapModule);
|
mProcessingChain.addModule(mSampleStreamTapModule);
|
||||||
Source source = mProcessingChain.getSource();
|
Source source = mProcessingChain.getSource();
|
||||||
|
|
||||||
if(source instanceof TunerChannelSource tcs)
|
int bandwidth = 0;
|
||||||
{
|
|
||||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
|
|
||||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
||||||
|
|
||||||
if(channel != null)
|
if(channel != null)
|
||||||
@@ -428,9 +408,14 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
|
|
||||||
if(!tunerChannels.isEmpty())
|
if(!tunerChannels.isEmpty())
|
||||||
{
|
{
|
||||||
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
|
bandwidth = tunerChannels.getFirst().getBandwidth();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(source instanceof TunerChannelSource tcs)
|
||||||
|
{
|
||||||
|
setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFFTProcessing();
|
updateFFTProcessing();
|
||||||
@@ -486,7 +471,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
|
|
||||||
//We se the offset cursor line to negative so that it aligns with where the signal is located
|
//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.
|
//instead of the correction that's needed to bring the signal to zero.
|
||||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
|
setCarrierOffset(-status.getDecoderCorrection());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,52 +480,4 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
|||||||
mSignalPowerView.receive(sourceEvent);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import javax.swing.JLabel;
|
|||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.UIManager;
|
import javax.swing.UIManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -574,9 +575,19 @@ public class JFrequencyControl extends JPanel implements ISourceEventProcessor
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void mouseWheelMoved(MouseWheelEvent e)
|
public void mouseWheelMoved(MouseWheelEvent e)
|
||||||
|
{
|
||||||
|
//Swing delivers mouse wheel events to disabled components, so check enabled state explicitly so that
|
||||||
|
//the wheel can't bypass the lock when the tuner is providing channels.
|
||||||
|
if(isEnabled())
|
||||||
{
|
{
|
||||||
set(mValue - e.getWheelRotation());
|
set(mValue - e.getWheelRotation());
|
||||||
}
|
}
|
||||||
|
else if(getParent() != null)
|
||||||
|
{
|
||||||
|
//Pass the event up so that an enclosing scroll pane can still scroll while this control is locked
|
||||||
|
getParent().dispatchEvent(SwingUtilities.convertMouseEvent(Digit.this, e, getParent()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
||||||
private RecordConfigurationEditor mRecordConfigurationEditor;
|
private RecordConfigurationEditor mRecordConfigurationEditor;
|
||||||
private ToggleSwitch mIgnoreDataCallsButton;
|
private ToggleSwitch mIgnoreDataCallsButton;
|
||||||
|
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||||
private SegmentedButton mModulationSegmentedButton;
|
private SegmentedButton mModulationSegmentedButton;
|
||||||
private ToggleButton mC4FMToggleButton;
|
private ToggleButton mC4FMToggleButton;
|
||||||
@@ -157,6 +158,15 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
GridPane.setConstraints(directionLabel, 5, row);
|
GridPane.setConstraints(directionLabel, 5, row);
|
||||||
gridPane.getChildren().add(directionLabel);
|
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++;
|
row++;
|
||||||
|
|
||||||
Label modulationHelpLabel = new Label("C4FM: repeaters and non-simulcast trunked systems. LSM: simulcast trunked systems.");
|
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;
|
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()
|
private ToggleSwitch getIgnoreDataCallsButton()
|
||||||
{
|
{
|
||||||
if(mIgnoreDataCallsButton == null)
|
if(mIgnoreDataCallsButton == null)
|
||||||
@@ -398,11 +423,13 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
protected void setDecoderConfiguration(DecodeConfiguration config)
|
protected void setDecoderConfiguration(DecodeConfiguration config)
|
||||||
{
|
{
|
||||||
getIgnoreDataCallsButton().setDisable(config == null);
|
getIgnoreDataCallsButton().setDisable(config == null);
|
||||||
|
getIgnoreEncryptedCallsButton().setDisable(config == null);
|
||||||
getTrafficChannelPoolSizeSpinner().setDisable(config == null);
|
getTrafficChannelPoolSizeSpinner().setDisable(config == null);
|
||||||
|
|
||||||
if(config instanceof DecodeConfigP25Phase1 decodeConfig)
|
if(config instanceof DecodeConfigP25Phase1 decodeConfig)
|
||||||
{
|
{
|
||||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||||
|
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||||
if(decodeConfig.getModulation() == Modulation.C4FM)
|
if(decodeConfig.getModulation() == Modulation.C4FM)
|
||||||
{
|
{
|
||||||
@@ -427,6 +454,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
getIgnoreDataCallsButton().setSelected(false);
|
getIgnoreDataCallsButton().setSelected(false);
|
||||||
|
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0);
|
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0);
|
||||||
getJmbeAGCButton().setDisable(true);
|
getJmbeAGCButton().setDisable(true);
|
||||||
getJmbeNoiseGainSlider().setDisable(true);
|
getJmbeNoiseGainSlider().setDisable(true);
|
||||||
@@ -454,6 +482,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||||
|
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||||
config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK);
|
config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK);
|
||||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
private IntegerTextField mSystemTextField;
|
private IntegerTextField mSystemTextField;
|
||||||
private IntegerTextField mNacTextField;
|
private IntegerTextField mNacTextField;
|
||||||
private ToggleSwitch mIgnoreDataCallsButton;
|
private ToggleSwitch mIgnoreDataCallsButton;
|
||||||
|
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||||
private ToggleSwitch mJmbeAGCButton;
|
private ToggleSwitch mJmbeAGCButton;
|
||||||
private Slider mJmbeToneGainSlider;
|
private Slider mJmbeToneGainSlider;
|
||||||
@@ -142,6 +143,15 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
GridPane.setConstraints(directionLabel, 3, row);
|
GridPane.setConstraints(directionLabel, 3, row);
|
||||||
gridPane.getChildren().add(directionLabel);
|
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");
|
Label wacnLabel = new Label("WACN");
|
||||||
wacnLabel.setLabelFor(getWacnTextField());
|
wacnLabel.setLabelFor(getWacnTextField());
|
||||||
GridPane.setHalignment(wacnLabel, HPos.RIGHT);
|
GridPane.setHalignment(wacnLabel, HPos.RIGHT);
|
||||||
@@ -300,6 +310,21 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
return mEventLogConfigurationEditor;
|
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()
|
private ToggleSwitch getIgnoreDataCallsButton()
|
||||||
{
|
{
|
||||||
if(mIgnoreDataCallsButton == null)
|
if(mIgnoreDataCallsButton == null)
|
||||||
@@ -417,6 +442,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
|
|
||||||
getIgnoreDataCallsButton().setDisable(false);
|
getIgnoreDataCallsButton().setDisable(false);
|
||||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||||
|
getIgnoreEncryptedCallsButton().setDisable(false);
|
||||||
|
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||||
getTrafficChannelPoolSizeSpinner().setDisable(false);
|
getTrafficChannelPoolSizeSpinner().setDisable(false);
|
||||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||||
getJmbeAGCButton().setSelected(decodeConfig.isAGC());
|
getJmbeAGCButton().setSelected(decodeConfig.isAGC());
|
||||||
@@ -433,6 +460,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
getSystemTextField().setDisable(true);
|
getSystemTextField().setDisable(true);
|
||||||
getNacTextField().setDisable(true);
|
getNacTextField().setDisable(true);
|
||||||
getIgnoreDataCallsButton().setDisable(true);
|
getIgnoreDataCallsButton().setDisable(true);
|
||||||
|
getIgnoreEncryptedCallsButton().setDisable(true);
|
||||||
|
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||||
getTrafficChannelPoolSizeSpinner().setDisable(true);
|
getTrafficChannelPoolSizeSpinner().setDisable(true);
|
||||||
getJmbeAGCButton().setDisable(true);
|
getJmbeAGCButton().setDisable(true);
|
||||||
getJmbeNoiseGainSlider().setDisable(true);
|
getJmbeNoiseGainSlider().setDisable(true);
|
||||||
@@ -465,6 +494,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
|||||||
int nac = getNacTextField().get();
|
int nac = getNacTextField().get();
|
||||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||||
|
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||||
config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue());
|
config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue());
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -92,8 +91,9 @@ public class AgencyEditor extends VBox
|
|||||||
|
|
||||||
if(agencies != null && !agencies.isEmpty())
|
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().addAll(agencies);
|
||||||
|
getAgencyListView().getItems().sort(new AgencyComparator());
|
||||||
|
|
||||||
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference;
|
|||||||
|
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import io.github.dsheirer.preference.UserPreferences;
|
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.Category;
|
||||||
import io.github.dsheirer.rrapi.type.Frequency;
|
import io.github.dsheirer.rrapi.type.Frequency;
|
||||||
import io.github.dsheirer.rrapi.type.Mode;
|
import io.github.dsheirer.rrapi.type.Mode;
|
||||||
@@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
{
|
{
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
if(categories != null)
|
|
||||||
{
|
|
||||||
Collections.sort(categories, new CategoryComparator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(categories != null && !categories.isEmpty())
|
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
|
// Only allow the combined list of all categories if there aren't that many
|
||||||
if (categories.size() < 10) {
|
if (sorted.size() < 10) {
|
||||||
categories.add(0, ALL_CATEGORIES);
|
sorted.add(0, ALL_CATEGORIES);
|
||||||
}
|
}
|
||||||
getCategoryComboBox().getItems().addAll(categories);
|
getCategoryComboBox().getItems().addAll(sorted);
|
||||||
getCategoryComboBox().getSelectionModel().select(categories.get(0));
|
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
|
|
||||||
Collections.sort(frequencies, new FrequencyComparator());
|
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(() -> {
|
Platform.runLater(() -> {
|
||||||
getFrequencyTableView().getItems().addAll(frequencies);
|
getFrequencyTableView().getItems().addAll(frequencies);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane
|
|||||||
catch(Throwable t)
|
catch(Throwable t)
|
||||||
{
|
{
|
||||||
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
|
|||||||
|
|
||||||
if(item != null)
|
if(item != null)
|
||||||
{
|
{
|
||||||
mFrequency = (long)(item.getDownlink() * 1E6);
|
mFrequency = item.getDownlinkHz();
|
||||||
getAlphaTagTextField().setText(item.getAlphaTag());
|
getAlphaTagTextField().setText(item.getAlphaTag());
|
||||||
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
||||||
getToneTextField().setText(item.getTone());
|
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)
|
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
|
||||||
{
|
{
|
||||||
if(modeDecoderType.hasDecoderType())
|
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TextField getSystemTextField()
|
private TextField getSystemTextField()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import io.github.dsheirer.rrapi.RadioReferenceException;
|
|||||||
import io.github.dsheirer.rrapi.response.Fault;
|
import io.github.dsheirer.rrapi.response.Fault;
|
||||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.Bindings;
|
import javafx.beans.binding.Bindings;
|
||||||
import javafx.event.ActionEvent;
|
import javafx.event.ActionEvent;
|
||||||
@@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -60,6 +60,8 @@ import java.util.Date;
|
|||||||
public class LoginDialog extends Dialog<AuthorizationInformation>
|
public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||||
{
|
{
|
||||||
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
|
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 UserPreferences mUserPreferences;
|
||||||
private TextField mUserNameText;
|
private TextField mUserNameText;
|
||||||
private PasswordField mPasswordField;
|
private PasswordField mPasswordField;
|
||||||
@@ -278,7 +280,7 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
{
|
{
|
||||||
if(mTestConnectionButton == null)
|
if(mTestConnectionButton == null)
|
||||||
{
|
{
|
||||||
mTestConnectionButton = new Button("Test Connection");
|
mTestConnectionButton = new Button(TEST_CONNECTION);
|
||||||
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
|
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
|
||||||
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
|
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
|
||||||
{
|
{
|
||||||
@@ -316,19 +318,91 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Platform.runLater(new Runnable()
|
getTestConnectionButton().setText(TESTING_CONNECTION);
|
||||||
{
|
|
||||||
@Override
|
//The test calls the web service, which can take up to the connect and request timeouts, so it runs
|
||||||
public void run()
|
//off the FX thread and the result is shown on the FX thread
|
||||||
{
|
ThreadPool.CACHED.execute(() -> {
|
||||||
boolean success = false;
|
RadioReference.LoginStatus loginStatus = null;
|
||||||
RadioReference.LoginStatus loginStatus;
|
Exception error = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
|
final RadioReference.LoginStatus status = loginStatus;
|
||||||
|
final Exception exception = error;
|
||||||
|
Platform.runLater(() -> showTestConnectionResult(status, exception));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
getTestPassIcon().setVisible(true);
|
||||||
getTestFailIcon().setVisible(false);
|
getTestFailIcon().setVisible(false);
|
||||||
@@ -336,84 +410,45 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
|||||||
//Success is only shown as an icon, so report it in the button's accessible name
|
//Success is only shown as an icon, so report it in the button's accessible name
|
||||||
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
||||||
}
|
}
|
||||||
else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
||||||
{
|
{
|
||||||
getTestPassIcon().setVisible(false);
|
getTestPassIcon().setVisible(false);
|
||||||
getTestFailIcon().setVisible(false);
|
getTestFailIcon().setVisible(false);
|
||||||
getTestExpiredIcon().setVisible(true);
|
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 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.setHeaderText("Expired Premium Account");
|
||||||
alert.setTitle("Radio Reference Account issue");
|
alert.setTitle("Radio Reference Account issue");
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||||
alert.showAndWait();
|
alert.showAndWait();
|
||||||
}
|
}
|
||||||
else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
}
|
||||||
|
else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
||||||
{
|
{
|
||||||
getTestPassIcon().setVisible(false);
|
getTestPassIcon().setVisible(false);
|
||||||
getTestFailIcon().setVisible(true);
|
getTestFailIcon().setVisible(true);
|
||||||
getTestExpiredIcon().setVisible(false);
|
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");
|
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
|
if(showing)
|
||||||
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);
|
showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password");
|
||||||
alert.setHeaderText("No Network Connection");
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.setTitle("Test Failed");
|
||||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||||
alert.showAndWait();
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mTestConnectionButton;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private IconNode getTestPassIcon()
|
private IconNode getTestPassIcon()
|
||||||
|
|||||||
@@ -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
|
* @param mode to match
|
||||||
* @return matching entry or UNKNOWN
|
* @return matching entry or UNKNOWN
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@
|
|||||||
|
|
||||||
package io.github.dsheirer.gui.playlist.radioreference;
|
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.alias.Alias;
|
||||||
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
|
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
|
||||||
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
||||||
@@ -382,7 +392,7 @@ public class RadioReferenceDecoder
|
|||||||
|
|
||||||
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
||||||
timeslotFrequency.setNumber(lcn);
|
timeslotFrequency.setNumber(lcn);
|
||||||
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
|
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
|
||||||
frequencies.add(timeslotFrequency);
|
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;
|
return frequencies;
|
||||||
@@ -577,4 +587,126 @@ public class RadioReferenceDecoder
|
|||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
package io.github.dsheirer.gui.playlist.radioreference;
|
package io.github.dsheirer.gui.playlist.radioreference;
|
||||||
|
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
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.UserPreferences;
|
||||||
|
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||||
import io.github.dsheirer.rrapi.type.Agency;
|
import io.github.dsheirer.rrapi.type.Agency;
|
||||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
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.State;
|
||||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||||
|
import io.github.dsheirer.service.radioreference.UserLocation;
|
||||||
import io.github.dsheirer.util.ThreadPool;
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.Bindings;
|
import javafx.beans.binding.Bindings;
|
||||||
@@ -42,12 +47,12 @@ import javafx.scene.control.Label;
|
|||||||
import javafx.scene.control.Tab;
|
import javafx.scene.control.Tab;
|
||||||
import javafx.scene.control.TabPane;
|
import javafx.scene.control.TabPane;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.ColumnConstraints;
|
import javafx.scene.layout.ColumnConstraints;
|
||||||
import javafx.scene.layout.GridPane;
|
import javafx.scene.layout.GridPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.util.StringConverter;
|
import javafx.util.StringConverter;
|
||||||
@@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
|
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
|
||||||
@@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
private PlaylistManager mPlaylistManager;
|
private PlaylistManager mPlaylistManager;
|
||||||
private VBox mTopBox;
|
private VBox mTopBox;
|
||||||
private HBox mCredentialsBox;
|
private HBox mCredentialsBox;
|
||||||
|
private HBox mLocationBox;
|
||||||
|
private Label mLocationText;
|
||||||
|
private final AtomicLong mLocationRequest = new AtomicLong();
|
||||||
|
private volatile String mLocationKey;
|
||||||
private Label mRRStatusText;
|
private Label mRRStatusText;
|
||||||
private TextField mUserNameText;
|
private TextField mUserNameText;
|
||||||
private TextField mAccountExpiresText;
|
private TextField mAccountExpiresText;
|
||||||
@@ -113,20 +123,41 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
{
|
{
|
||||||
refreshCountries();
|
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()
|
private void login()
|
||||||
{
|
{
|
||||||
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
|
//Logs in on a background thread, unless the spectral display has already logged in
|
||||||
{
|
mRadioReference.loginWithStoredCredentials();
|
||||||
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
|
|
||||||
|
|
||||||
if(credentials != null)
|
|
||||||
{
|
|
||||||
ThreadPool.CACHED.execute(() -> accept(credentials));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
mTopBox = new VBox();
|
mTopBox = new VBox();
|
||||||
mTopBox.setSpacing(5);
|
mTopBox.setSpacing(5);
|
||||||
mTopBox.setPadding(new Insets(10,10,10,10));
|
mTopBox.setPadding(new Insets(10,10,10,10));
|
||||||
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
|
mTopBox.getChildren().addAll(getCredentialsBox(), getLocationBox(), getComboBoxPane());
|
||||||
}
|
}
|
||||||
|
|
||||||
return mTopBox;
|
return mTopBox;
|
||||||
@@ -416,13 +447,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
mCredentialsBox = new HBox();
|
mCredentialsBox = new HBox();
|
||||||
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
|
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||||
mCredentialsBox.setSpacing(5.0);
|
mCredentialsBox.setSpacing(5.0);
|
||||||
Region leftFiller = new Region();
|
|
||||||
HBox.setHgrow(leftFiller, Priority.ALWAYS);
|
|
||||||
mCredentialsBox.getChildren().add(leftFiller);
|
|
||||||
mCredentialsBox.getChildren().add(getRRPremiumReqText());
|
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(new Label("User Name:"));
|
||||||
mCredentialsBox.getChildren().add(getUserNameText());
|
mCredentialsBox.getChildren().add(getUserNameText());
|
||||||
mCredentialsBox.getChildren().add(new Label("Expires:"));
|
mCredentialsBox.getChildren().add(new Label("Expires:"));
|
||||||
@@ -446,8 +471,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
Bindings.and(
|
Bindings.and(
|
||||||
mRadioReference.availableProperty(),
|
mRadioReference.availableProperty(),
|
||||||
mRadioReference.premiumAccountProperty().not()));
|
mRadioReference.premiumAccountProperty().not()));
|
||||||
mRRStatusText.setTextFill(Color.ORANGERED);
|
//Inline style, because the theme stylesheets override a label's text fill
|
||||||
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
|
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
|
||||||
}
|
}
|
||||||
|
|
||||||
return mRRStatusText;
|
return mRRStatusText;
|
||||||
@@ -725,6 +750,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
if(selected != null)
|
if(selected != null)
|
||||||
{
|
{
|
||||||
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
||||||
|
refreshLocation();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -732,6 +758,111 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
|||||||
return mCountyComboBox;
|
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
|
* Consumer interface method for receiving login credentials
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,118 +247,7 @@ public class SiteEditor extends GridPane
|
|||||||
*/
|
*/
|
||||||
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
||||||
{
|
{
|
||||||
if(decoderType == null)
|
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,7 +510,7 @@ public class SiteEditor extends GridPane
|
|||||||
if(controls.size() == 1)
|
if(controls.size() == 1)
|
||||||
{
|
{
|
||||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||||
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
|
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
|
||||||
channel.setSourceConfiguration(sourceConfigTuner);
|
channel.setSourceConfiguration(sourceConfigTuner);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -630,7 +519,7 @@ public class SiteEditor extends GridPane
|
|||||||
|
|
||||||
for(SiteFrequency control: controls)
|
for(SiteFrequency control: controls)
|
||||||
{
|
{
|
||||||
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
|
sourceConfig.addFrequency(control.getFrequencyHz());
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.setSourceConfiguration(sourceConfig);
|
channel.setSourceConfiguration(sourceConfig);
|
||||||
@@ -856,7 +745,7 @@ public class SiteEditor extends GridPane
|
|||||||
|
|
||||||
private static long getFrequency(SiteFrequency siteFrequency)
|
private static long getFrequency(SiteFrequency siteFrequency)
|
||||||
{
|
{
|
||||||
return (long)(siteFrequency.getFrequency() * 1E6);
|
return siteFrequency.getFrequencyHz();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -139,8 +139,9 @@ public class SystemEditor extends VBox
|
|||||||
|
|
||||||
if(systems != null && !systems.isEmpty())
|
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().addAll(systems);
|
||||||
|
getSystemComboBox().getItems().sort(new SystemComparator());
|
||||||
|
|
||||||
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
||||||
|
|
||||||
@@ -301,9 +302,18 @@ public class SystemEditor extends VBox
|
|||||||
//Temporary sites that have been added to a system where the county/location is unknown, can
|
//Temporary sites that have been added to a system where the county/location is unknown, can
|
||||||
//have a county ID of 99,999 as observed from API (undocumented).
|
//have a county ID of 99,999 as observed from API (undocumented).
|
||||||
if(countyId > 0 && countyId != 99999)
|
if(countyId > 0 && countyId != 99999)
|
||||||
|
{
|
||||||
|
//The county name is only for display, so a failed lookup shouldn't fail the system load
|
||||||
|
try
|
||||||
{
|
{
|
||||||
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
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));
|
enrichedSites.add(new EnrichedSite(site, countyInfo));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane
|
|||||||
|
|
||||||
if(talkgroups != null && !talkgroups.isEmpty())
|
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)));
|
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
|||||||
import io.github.dsheirer.audio.broadcast.BroadcastEvent;
|
import io.github.dsheirer.audio.broadcast.BroadcastEvent;
|
||||||
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
|
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
|
||||||
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
|
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
|
||||||
|
import io.github.dsheirer.gui.control.IntegerTextField;
|
||||||
import io.github.dsheirer.gui.playlist.Editor;
|
import io.github.dsheirer.gui.playlist.Editor;
|
||||||
import io.github.dsheirer.playlist.PlaylistManager;
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
@@ -68,6 +69,26 @@ public abstract class AbstractBroadcastEditor<T extends BroadcastConfiguration>
|
|||||||
buttonBox.setSpacing(10);
|
buttonBox.setSpacing(10);
|
||||||
buttonBox.getChildren().addAll(getSaveButton(), getResetButton());
|
buttonBox.getChildren().addAll(getSaveButton(), getResetButton());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow column 1 (Primary input fields) to expand with editor width
|
||||||
|
*/
|
||||||
|
for(Node node : getEditorPane().getChildren())
|
||||||
|
{
|
||||||
|
Integer column = GridPane.getColumnIndex(node);
|
||||||
|
|
||||||
|
if(column != null && column == 1)
|
||||||
|
{
|
||||||
|
if(node instanceof IntegerTextField)
|
||||||
|
{
|
||||||
|
GridPane.setFillWidth(node, false);
|
||||||
|
}
|
||||||
|
else if(node instanceof TextField)
|
||||||
|
{
|
||||||
|
GridPane.setHgrow(node, Priority.ALWAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HBox editorBox = new HBox();
|
HBox editorBox = new HBox();
|
||||||
HBox.setHgrow(getEditorPane(), Priority.ALWAYS);
|
HBox.setHgrow(getEditorPane(), Priority.ALWAYS);
|
||||||
editorBox.getChildren().addAll(getEditorPane(), buttonBox);
|
editorBox.getChildren().addAll(getEditorPane(), buttonBox);
|
||||||
|
|||||||
@@ -119,6 +119,22 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
|||||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
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()
|
protected GridPane getEditorPane()
|
||||||
{
|
{
|
||||||
if(mEditorPane == null)
|
if(mEditorPane == null)
|
||||||
@@ -157,7 +173,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
|||||||
GridPane.setConstraints(getNameTextField(), 1, row);
|
GridPane.setConstraints(getNameTextField(), 1, row);
|
||||||
mEditorPane.getChildren().add(getNameTextField());
|
mEditorPane.getChildren().add(getNameTextField());
|
||||||
|
|
||||||
Label apiKeyLabel = new Label("API Key");
|
Label apiKeyLabel = new Label(getApiKeyLabel());
|
||||||
apiKeyLabel.setLabelFor(getApiKeyTextField());
|
apiKeyLabel.setLabelFor(getApiKeyTextField());
|
||||||
GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
|
GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
|
||||||
GridPane.setConstraints(apiKeyLabel, 0, ++row);
|
GridPane.setConstraints(apiKeyLabel, 0, ++row);
|
||||||
@@ -175,7 +191,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
|||||||
GridPane.setConstraints(getSystemIdTextField(), 1, row);
|
GridPane.setConstraints(getSystemIdTextField(), 1, row);
|
||||||
mEditorPane.getChildren().add(getSystemIdTextField());
|
mEditorPane.getChildren().add(getSystemIdTextField());
|
||||||
|
|
||||||
Label hostLabel = new Label("RdioScanner URL");
|
Label hostLabel = new Label(getHostLabel());
|
||||||
hostLabel.setLabelFor(getHostTextField());
|
hostLabel.setLabelFor(getHostTextField());
|
||||||
GridPane.setHalignment(hostLabel, HPos.RIGHT);
|
GridPane.setHalignment(hostLabel, HPos.RIGHT);
|
||||||
GridPane.setConstraints(hostLabel, 0, ++row);
|
GridPane.setConstraints(hostLabel, 0, ++row);
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ public class StreamEditorFactory
|
|||||||
return new BroadcastifyStreamEditor(playlistManager);
|
return new BroadcastifyStreamEditor(playlistManager);
|
||||||
case RDIOSCANNER_CALL:
|
case RDIOSCANNER_CALL:
|
||||||
return new RdioScannerEditor(playlistManager);
|
return new RdioScannerEditor(playlistManager);
|
||||||
|
case THINLINE_RADIO:
|
||||||
|
return new ThinlineRadioEditor(playlistManager);
|
||||||
case OPENMHZ:
|
case OPENMHZ:
|
||||||
return new OpenMHzEditor(playlistManager);
|
return new OpenMHzEditor(playlistManager);
|
||||||
case BROADCASTIFY_CALL:
|
case BROADCASTIFY_CALL:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* *****************************************************************************
|
* *****************************************************************************
|
||||||
* Copyright (C) 2014-2023 Dennis Sheirer
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
@@ -17,37 +17,36 @@
|
|||||||
* ****************************************************************************
|
* ****************************************************************************
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
|
package io.github.dsheirer.gui.playlist.streaming;
|
||||||
|
|
||||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
|
||||||
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LRRP Trigger on Move Token
|
* Thinline Radio call upload configuration editor.
|
||||||
*
|
*
|
||||||
* Total Length: 1 byte
|
* The form and upload path match the Rdio Scanner editor.
|
||||||
*/
|
*/
|
||||||
public class TriggerOnMove extends Token
|
public class ThinlineRadioEditor extends RdioScannerEditor
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Constructs an instance of a heading token.
|
* Constructs an instance
|
||||||
*
|
* @param playlistManager for accessing the broadcast model
|
||||||
* @param message containing the heading
|
|
||||||
* @param offset to the start of the token
|
|
||||||
*/
|
*/
|
||||||
public TriggerOnMove(CorrectedBinaryMessage message, int offset)
|
public ThinlineRadioEditor(PlaylistManager playlistManager)
|
||||||
{
|
{
|
||||||
super(message, offset);
|
super(playlistManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TokenType getTokenType()
|
public BroadcastServerType getBroadcastServerType()
|
||||||
{
|
{
|
||||||
return TokenType.TRIGGER_ON_MOVE;
|
return BroadcastServerType.THINLINE_RADIO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString()
|
protected String getHostLabel()
|
||||||
{
|
{
|
||||||
return "TRIGGER ON MOVE";
|
return "Thinline Radio URL";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.buffer.INativeBuffer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.ChannelTuningLayer;
|
||||||
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
|
import io.github.dsheirer.sample.Listener;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spectral display for a single decoding channel's sample stream, centered on the channel frequency. The channel
|
||||||
|
* tuning layer marks the channel passband and the decoder's estimated carrier position (PLL).
|
||||||
|
*
|
||||||
|
* Construct on the JavaFX application thread. The other methods can be called from any thread. Feed the channel's
|
||||||
|
* complex sample buffers to getSampleListener() and turn processing on with setActive(true) while the display is
|
||||||
|
* showing.
|
||||||
|
*/
|
||||||
|
public class ChannelSpectralDisplay extends DftSpectralDisplay
|
||||||
|
{
|
||||||
|
public static final String SETTINGS_PREFIX = "channel.spectral.display";
|
||||||
|
public static final double DEFAULT_SAMPLE_SIZE = 18.0;
|
||||||
|
public static final double MINIMUM_SAMPLE_SIZE = 8.0;
|
||||||
|
public static final double MAXIMUM_SAMPLE_SIZE = 36.0;
|
||||||
|
private static final String NOISE_FLOOR = "noise.floor";
|
||||||
|
private static final String MESSAGE_NO_CHANNEL = "Select a decoding channel to view its spectrum";
|
||||||
|
|
||||||
|
private final ChannelTuningLayer mTuningLayer;
|
||||||
|
private final EdgeSlider mNoiseFloorSlider;
|
||||||
|
private final Object mLock = new Object();
|
||||||
|
private boolean mActive;
|
||||||
|
private boolean mHasChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
*/
|
||||||
|
public ChannelSpectralDisplay()
|
||||||
|
{
|
||||||
|
this(new ChannelTuningLayer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelSpectralDisplay(ChannelTuningLayer tuningLayer)
|
||||||
|
{
|
||||||
|
super(SETTINGS_PREFIX, true, tuningLayer);
|
||||||
|
mTuningLayer = tuningLayer;
|
||||||
|
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||||
|
getSpectralDisplay().messageProperty().set(MESSAGE_NO_CHANNEL);
|
||||||
|
|
||||||
|
//Noise floor slider along the right edge, below the configuration panel tab
|
||||||
|
mNoiseFloorSlider = new EdgeSlider("Floor", "Noise floor: moves the bottom of the spectrum's dB range. " +
|
||||||
|
"Higher settings show a deeper range and move the noise floor up the display.",
|
||||||
|
MINIMUM_SAMPLE_SIZE, MAXIMUM_SAMPLE_SIZE, 1, this::formatPlotBottom);
|
||||||
|
double sampleSize = SystemProperties.getInstance().get(key(NOISE_FLOOR), (int)DEFAULT_SAMPLE_SIZE);
|
||||||
|
sampleSize = Math.max(MINIMUM_SAMPLE_SIZE, Math.min(MAXIMUM_SAMPLE_SIZE, sampleSize));
|
||||||
|
model.sampleSizeProperty().set(sampleSize);
|
||||||
|
mNoiseFloorSlider.valueProperty().set(sampleSize);
|
||||||
|
mNoiseFloorSlider.valueProperty().addListener((ob, ov, nv) -> {
|
||||||
|
int value = (int)Math.round(nv.doubleValue());
|
||||||
|
model.sampleSizeProperty().set(value);
|
||||||
|
save(NOISE_FLOOR, value);
|
||||||
|
});
|
||||||
|
//The value shows the plot bottom in dBFS, which also depends on the DFT size and window
|
||||||
|
model.binCountProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
|
||||||
|
model.noiseBandwidthProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
|
||||||
|
mNoiseFloorSlider.refreshLabel();
|
||||||
|
StackPane.setAlignment(mNoiseFloorSlider, Pos.CENTER_RIGHT);
|
||||||
|
StackPane.setMargin(mNoiseFloorSlider, new Insets(44, 0, 8, 0));
|
||||||
|
getChildren().add(mNoiseFloorSlider);
|
||||||
|
|
||||||
|
installConfigPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener for the channel's complex sample buffers.
|
||||||
|
*/
|
||||||
|
public Listener<INativeBuffer> getSampleListener()
|
||||||
|
{
|
||||||
|
return getDftProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a channel.
|
||||||
|
* @param frequency of the channel center in Hertz
|
||||||
|
* @param sampleRate of the channel sample stream in Hertz
|
||||||
|
* @param bandwidth of the channel in Hertz, or zero to hide the passband markers
|
||||||
|
*/
|
||||||
|
public void setChannel(long frequency, int sampleRate, int bandwidth)
|
||||||
|
{
|
||||||
|
synchronized(mLock)
|
||||||
|
{
|
||||||
|
mHasChannel = sampleRate > 0;
|
||||||
|
updateProcessing();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpectralDisplay().clear();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||||
|
model.resetZoom();
|
||||||
|
model.centerFrequencyProperty().set(frequency);
|
||||||
|
model.sampleRateProperty().set(sampleRate);
|
||||||
|
mTuningLayer.channelBandwidthProperty().set(bandwidth);
|
||||||
|
mTuningLayer.carrierOffsetValidProperty().set(false);
|
||||||
|
mTuningLayer.carrierOffsetProperty().set(0);
|
||||||
|
getSpectralDisplay().messageProperty().set(sampleRate > 0 ? null : MESSAGE_NO_CHANNEL);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops showing the channel.
|
||||||
|
*/
|
||||||
|
public void clearChannel()
|
||||||
|
{
|
||||||
|
setChannel(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the estimated carrier offset from the channel center, from the decoder's frequency correction.
|
||||||
|
* @param offset in Hertz: where the signal is relative to the channel center
|
||||||
|
*/
|
||||||
|
public void setCarrierOffset(long offset)
|
||||||
|
{
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
mTuningLayer.carrierOffsetProperty().set(offset);
|
||||||
|
mTuningLayer.carrierOffsetValidProperty().set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Noise floor slider along the right edge of the display.
|
||||||
|
*/
|
||||||
|
public EdgeSlider getNoiseFloorSlider()
|
||||||
|
{
|
||||||
|
return mNoiseFloorSlider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the bottom of the spectrum plot in dBFS for the noise floor slider value.
|
||||||
|
*/
|
||||||
|
private String formatPlotBottom(double sampleSize)
|
||||||
|
{
|
||||||
|
double range = (20.0 * Math.log10(Math.pow(2.0, sampleSize - 1.0))) +
|
||||||
|
getSpectralDisplay().getModel().getProcessingGain();
|
||||||
|
return Math.round(-range) + " dB";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns DFT processing on while the display is showing, and off when it's hidden.
|
||||||
|
*/
|
||||||
|
public void setActive(boolean active)
|
||||||
|
{
|
||||||
|
synchronized(mLock)
|
||||||
|
{
|
||||||
|
mActive = active;
|
||||||
|
updateProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void processingEnabledChanged()
|
||||||
|
{
|
||||||
|
synchronized(mLock)
|
||||||
|
{
|
||||||
|
updateProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or stops the DFT processor. Hold the lock when calling.
|
||||||
|
*/
|
||||||
|
private void updateProcessing()
|
||||||
|
{
|
||||||
|
if(mActive && mHasChannel && isProcessingEnabled())
|
||||||
|
{
|
||||||
|
startDft();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stopDft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
setActive(false);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import org.controlsfx.control.ToggleSwitch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labeled control that a display layer adds to its section of the configuration panel.
|
||||||
|
*
|
||||||
|
* @param label shown to the left of the control
|
||||||
|
* @param control to change the setting
|
||||||
|
*/
|
||||||
|
public record ConfigurationRow(String label, Node control)
|
||||||
|
{
|
||||||
|
private static final double SWITCH_WIDTH = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a row with an on/off switch bound to the property.
|
||||||
|
*/
|
||||||
|
public static ConfigurationRow toggle(String label, BooleanProperty property)
|
||||||
|
{
|
||||||
|
ToggleSwitch toggleSwitch = new ToggleSwitch();
|
||||||
|
toggleSwitch.selectedProperty().bindBidirectional(property);
|
||||||
|
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
|
||||||
|
toggleSwitch.setMinWidth(SWITCH_WIDTH);
|
||||||
|
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
|
||||||
|
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
|
||||||
|
return new ConfigurationRow(label, toggleSwitch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text and placement of the cursor readout: the frequency under the mouse, and the level in the spectrum, in a box
|
||||||
|
* beside the mouse. The frequency layer draws the readout. A layer that replaces the readout with a larger callout
|
||||||
|
* (see {@link SpectralDisplayLayer#replacesCursorReadout}) uses the same text and placement, so the readout appears to
|
||||||
|
* grow into the callout.
|
||||||
|
*/
|
||||||
|
public final class CursorReadout
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Horizontal distance between the mouse and the box
|
||||||
|
*/
|
||||||
|
public static final double GAP_X = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical distance between the box and the mouse below it
|
||||||
|
*/
|
||||||
|
public static final double GAP_Y = 4;
|
||||||
|
|
||||||
|
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||||
|
private static final DecimalFormat LEVEL_FORMAT = new DecimalFormat("0.0");
|
||||||
|
|
||||||
|
private CursorReadout()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readout lines: the frequency under the mouse, and the level when the mouse is over the spectrum plot
|
||||||
|
*/
|
||||||
|
public static List<String> lines(DisplayCursor cursor, RenderArea area, SpectralDisplayModel model)
|
||||||
|
{
|
||||||
|
List<String> lines = new ArrayList<>(2);
|
||||||
|
lines.add(FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz");
|
||||||
|
|
||||||
|
if(cursor.section() == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
|
||||||
|
{
|
||||||
|
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
|
||||||
|
lines.add(LEVEL_FORMAT.format(decibels) + " dBFS");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left edge of a box beside the mouse: to the right of the mouse, or to the left when it doesn't fit
|
||||||
|
*/
|
||||||
|
public static double x(DisplayCursor cursor, double width, RenderArea area)
|
||||||
|
{
|
||||||
|
double x = cursor.x() + GAP_X;
|
||||||
|
|
||||||
|
if(x + width > area.width())
|
||||||
|
{
|
||||||
|
x = cursor.x() - GAP_X - width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top edge of a box above the mouse, kept inside the plot
|
||||||
|
*/
|
||||||
|
public static double y(DisplayCursor cursor, double height, RenderArea area)
|
||||||
|
{
|
||||||
|
return Math.max(0, Math.min(area.plotHeight() - height, cursor.y() - height - GAP_Y));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.dsp.window.WindowType;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.DecibelLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.FrequencyLayer;
|
||||||
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
|
import io.github.dsheirer.spectrum.ComplexDftProcessor;
|
||||||
|
import io.github.dsheirer.spectrum.DFTSize;
|
||||||
|
import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.IntegerProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleIntegerProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a spectral display fed by DFT processing of a complex sample stream: the spectral display with the
|
||||||
|
* decibel and frequency layers plus any layers the subclass adds, the DFT processor and dBFS converter, the
|
||||||
|
* collapsible configuration panel, and saving the display settings under a property key prefix.
|
||||||
|
*
|
||||||
|
* The color theme is shared by all spectral displays in the application.
|
||||||
|
*
|
||||||
|
* Construct on the JavaFX application thread. Subclasses call installConfigPanel() at the end of their constructor.
|
||||||
|
*/
|
||||||
|
public abstract class DftSpectralDisplay extends StackPane
|
||||||
|
{
|
||||||
|
public static final int[] FRAME_RATES = {10, 14, 16, 18, 20, 25, 30, 40, 50, 60};
|
||||||
|
public static final String PROPERTY_THEME = "spectral.display.theme";
|
||||||
|
private static final String DFT_SIZE = "dft.size";
|
||||||
|
private static final String WINDOW_TYPE = "window.type";
|
||||||
|
private static final String FRAME_RATE = "frame.rate";
|
||||||
|
private static final String AVERAGING = "averaging";
|
||||||
|
private static final String PEAK_HOLD = "peak.hold";
|
||||||
|
private static final String SPECTRUM_ENABLED = "spectrum.enabled";
|
||||||
|
private static final String WATERFALL_ENABLED = "waterfall.enabled";
|
||||||
|
private static final String DIVIDER_POSITION = "divider.position";
|
||||||
|
private static final String DECIBEL_SCALE = "decibel.scale";
|
||||||
|
private static final String CONFIG_PINNED = "config.pinned";
|
||||||
|
private static ObjectProperty<SpectralDisplayTheme> sSharedTheme;
|
||||||
|
|
||||||
|
private final SpectralDisplay mSpectralDisplay;
|
||||||
|
private final DecibelLayer mDecibelLayer = new DecibelLayer();
|
||||||
|
private final ComplexDftProcessor mDftProcessor = new ComplexDftProcessor();
|
||||||
|
private final FullScaleDecibelConverter mDecibelConverter = new FullScaleDecibelConverter();
|
||||||
|
private final String mSettingsPrefix;
|
||||||
|
private final boolean mSaveSettings;
|
||||||
|
private final ObjectProperty<DFTSize> mDftSize = new SimpleObjectProperty<>(DFTSize.FFT04096);
|
||||||
|
private final ObjectProperty<WindowType> mWindowType = new SimpleObjectProperty<>(WindowType.BLACKMAN_HARRIS_7);
|
||||||
|
private final IntegerProperty mFrameRate = new SimpleIntegerProperty(20);
|
||||||
|
private SpectralDisplayConfigPanel mConfigPanel;
|
||||||
|
private volatile boolean mProcessingEnabled;
|
||||||
|
private volatile boolean mDftRunning;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param settingsPrefix for the saved setting keys, for example "spectral.display"
|
||||||
|
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||||
|
* @param layers to draw after the decibel scale and frequency layers
|
||||||
|
*/
|
||||||
|
protected DftSpectralDisplay(String settingsPrefix, boolean saveSettings, SpectralDisplayLayer... layers)
|
||||||
|
{
|
||||||
|
mSettingsPrefix = settingsPrefix;
|
||||||
|
mSaveSettings = saveSettings;
|
||||||
|
|
||||||
|
SpectralDisplayLayer[] all = new SpectralDisplayLayer[layers.length + 2];
|
||||||
|
all[0] = mDecibelLayer;
|
||||||
|
all[1] = new FrequencyLayer();
|
||||||
|
System.arraycopy(layers, 0, all, 2, layers.length);
|
||||||
|
mSpectralDisplay = new SpectralDisplay(all);
|
||||||
|
|
||||||
|
mDftProcessor.setUnityGainWindow(true);
|
||||||
|
mDftProcessor.addConverter(mDecibelConverter);
|
||||||
|
mDecibelConverter.addListener(mSpectralDisplay::receive);
|
||||||
|
//The processor starts its timer on construction - leave it stopped until there is a sample source
|
||||||
|
mDftProcessor.stop();
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
|
||||||
|
mDftSize.addListener((ob, ov, nv) -> {
|
||||||
|
mDftProcessor.setDFTSize(nv);
|
||||||
|
mSpectralDisplay.getModel().resetZoom();
|
||||||
|
save(DFT_SIZE, nv.name());
|
||||||
|
});
|
||||||
|
mWindowType.addListener((ob, ov, nv) -> {
|
||||||
|
mDftProcessor.setWindowType(nv);
|
||||||
|
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(nv));
|
||||||
|
save(WINDOW_TYPE, nv.name());
|
||||||
|
});
|
||||||
|
mFrameRate.addListener((ob, ov, nv) -> {
|
||||||
|
mDftProcessor.setFrameRateNoSave(nv.intValue());
|
||||||
|
|
||||||
|
if(!mDftRunning)
|
||||||
|
{
|
||||||
|
//Changing the frame rate restarts the timer - keep it stopped while not processing
|
||||||
|
mDftProcessor.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
save(FRAME_RATE, nv.intValue());
|
||||||
|
});
|
||||||
|
mSpectralDisplay.averagingProperty().addListener((ob, ov, nv) -> save(AVERAGING, nv.intValue()));
|
||||||
|
mSpectralDisplay.peakHoldProperty().addListener((ob, ov, nv) -> save(PEAK_HOLD, nv));
|
||||||
|
mSpectralDisplay.dividerPositionProperty().addListener((ob, ov, nv) ->
|
||||||
|
save(DIVIDER_POSITION, String.valueOf(nv.doubleValue())));
|
||||||
|
mDecibelLayer.visibleProperty().addListener((ob, ov, nv) -> save(DECIBEL_SCALE, nv));
|
||||||
|
mSpectralDisplay.spectrumEnabledProperty().addListener((ob, ov, nv) -> {
|
||||||
|
save(SPECTRUM_ENABLED, nv);
|
||||||
|
updateProcessingEnabled();
|
||||||
|
});
|
||||||
|
mSpectralDisplay.waterfallEnabledProperty().addListener((ob, ov, nv) -> {
|
||||||
|
save(WATERFALL_ENABLED, nv);
|
||||||
|
updateProcessingEnabled();
|
||||||
|
});
|
||||||
|
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
|
||||||
|
mSpectralDisplay.waterfallEnabledProperty().get();
|
||||||
|
|
||||||
|
mSpectralDisplay.themeProperty().bindBidirectional(getSharedTheme());
|
||||||
|
|
||||||
|
getChildren().add(mSpectralDisplay);
|
||||||
|
setMinSize(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the configuration panel. Subclasses call this at the end of their constructor, after their own
|
||||||
|
* settings are loaded, since the panel reads the subclass's controls.
|
||||||
|
*/
|
||||||
|
protected void installConfigPanel()
|
||||||
|
{
|
||||||
|
mConfigPanel = new SpectralDisplayConfigPanel(this);
|
||||||
|
mConfigPanel.pinnedProperty().set(SystemProperties.getInstance().get(key(CONFIG_PINNED), false));
|
||||||
|
mConfigPanel.pinnedProperty().addListener((ob, ov, nv) -> save(CONFIG_PINNED, nv));
|
||||||
|
StackPane.setAlignment(mConfigPanel, Pos.TOP_RIGHT);
|
||||||
|
StackPane.setMargin(mConfigPanel, new Insets(6));
|
||||||
|
getChildren().add(mConfigPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color theme shared by all spectral displays, loaded from and saved to the theme setting. JavaFX thread only.
|
||||||
|
*/
|
||||||
|
public static ObjectProperty<SpectralDisplayTheme> getSharedTheme()
|
||||||
|
{
|
||||||
|
if(sSharedTheme == null)
|
||||||
|
{
|
||||||
|
SpectralDisplayTheme theme = parse(SpectralDisplayTheme.class, SystemProperties.getInstance()
|
||||||
|
.get(PROPERTY_THEME, SpectralDisplayTheme.getDefault().name()), SpectralDisplayTheme.getDefault());
|
||||||
|
sSharedTheme = new SimpleObjectProperty<>(theme);
|
||||||
|
sSharedTheme.addListener((ob, ov, nv) -> {
|
||||||
|
if(nv != null)
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(PROPERTY_THEME, nv.name());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sSharedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadSettings()
|
||||||
|
{
|
||||||
|
SystemProperties properties = SystemProperties.getInstance();
|
||||||
|
DFTSize dftSize = parse(DFTSize.class, properties.get(key(DFT_SIZE), DFTSize.FFT04096.name()),
|
||||||
|
DFTSize.FFT04096);
|
||||||
|
mDftSize.set(dftSize);
|
||||||
|
mDftProcessor.setDFTSize(dftSize);
|
||||||
|
|
||||||
|
WindowType windowType = parse(WindowType.class, properties.get(key(WINDOW_TYPE),
|
||||||
|
WindowType.BLACKMAN_HARRIS_7.name()), WindowType.BLACKMAN_HARRIS_7);
|
||||||
|
|
||||||
|
if(!isWindowSupported(windowType))
|
||||||
|
{
|
||||||
|
windowType = WindowType.BLACKMAN_HARRIS_7;
|
||||||
|
}
|
||||||
|
|
||||||
|
mWindowType.set(windowType);
|
||||||
|
mDftProcessor.setWindowType(windowType);
|
||||||
|
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(windowType));
|
||||||
|
|
||||||
|
int frameRate = Math.max(1, Math.min(100, properties.get(key(FRAME_RATE), mDftProcessor.getFrameRate())));
|
||||||
|
mFrameRate.set(frameRate);
|
||||||
|
mDftProcessor.setFrameRateNoSave(frameRate);
|
||||||
|
mDftProcessor.stop();
|
||||||
|
|
||||||
|
int averaging = properties.get(key(AVERAGING), SpectralDisplay.AVERAGING_DEFAULT);
|
||||||
|
mSpectralDisplay.averagingProperty().set(Math.max(SpectralDisplay.AVERAGING_MINIMUM,
|
||||||
|
Math.min(SpectralDisplay.AVERAGING_MAXIMUM, averaging)));
|
||||||
|
mSpectralDisplay.peakHoldProperty().set(properties.get(key(PEAK_HOLD), false));
|
||||||
|
mDecibelLayer.visibleProperty().set(properties.get(key(DECIBEL_SCALE), true));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double divider = Double.parseDouble(properties.get(key(DIVIDER_POSITION), "0.5"));
|
||||||
|
mSpectralDisplay.dividerPositionProperty().set(Math.max(0.05, Math.min(0.95, divider)));
|
||||||
|
}
|
||||||
|
catch(NumberFormatException nfe)
|
||||||
|
{
|
||||||
|
mSpectralDisplay.dividerPositionProperty().set(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
mSpectralDisplay.spectrumEnabledProperty().set(properties.get(key(SPECTRUM_ENABLED), true));
|
||||||
|
mSpectralDisplay.waterfallEnabledProperty().set(properties.get(key(WATERFALL_ENABLED), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full property key for the setting name.
|
||||||
|
*/
|
||||||
|
protected String key(String name)
|
||||||
|
{
|
||||||
|
return mSettingsPrefix + "." + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the window type can be used for the spectral display. Kaiser windows need extra parameters.
|
||||||
|
*/
|
||||||
|
public static boolean isWindowSupported(WindowType windowType)
|
||||||
|
{
|
||||||
|
return windowType != null && windowType != WindowType.KAISER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an enum setting value, or returns the default for a missing or unrecognized value.
|
||||||
|
*/
|
||||||
|
protected static <E extends Enum<E>> E parse(Class<E> type, String value, E defaultValue)
|
||||||
|
{
|
||||||
|
if(value != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Enum.valueOf(type, value);
|
||||||
|
}
|
||||||
|
catch(IllegalArgumentException iae)
|
||||||
|
{
|
||||||
|
//Fall through to the default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void save(String name, String value)
|
||||||
|
{
|
||||||
|
if(mSaveSettings)
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(key(name), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void save(String name, int value)
|
||||||
|
{
|
||||||
|
if(mSaveSettings)
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(key(name), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void save(String name, boolean value)
|
||||||
|
{
|
||||||
|
if(mSaveSettings)
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(key(name), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spectral display.
|
||||||
|
*/
|
||||||
|
public SpectralDisplay getSpectralDisplay()
|
||||||
|
{
|
||||||
|
return mSpectralDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible configuration panel, or null before installConfigPanel() is called.
|
||||||
|
*/
|
||||||
|
public SpectralDisplayConfigPanel getConfigPanel()
|
||||||
|
{
|
||||||
|
return mConfigPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFT processor. Register it with the sample source to feed the display.
|
||||||
|
*/
|
||||||
|
protected ComplexDftProcessor getDftProcessor()
|
||||||
|
{
|
||||||
|
return mDftProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFT size.
|
||||||
|
*/
|
||||||
|
public ObjectProperty<DFTSize> dftSizeProperty()
|
||||||
|
{
|
||||||
|
return mDftSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFT window type.
|
||||||
|
*/
|
||||||
|
public ObjectProperty<WindowType> windowTypeProperty()
|
||||||
|
{
|
||||||
|
return mWindowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFT frames per second.
|
||||||
|
*/
|
||||||
|
public IntegerProperty frameRateProperty()
|
||||||
|
{
|
||||||
|
return mFrameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the spectrum or the waterfall is turned on. Safe to read from any thread.
|
||||||
|
*/
|
||||||
|
protected boolean isProcessingEnabled()
|
||||||
|
{
|
||||||
|
return mProcessingEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProcessingEnabled()
|
||||||
|
{
|
||||||
|
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
|
||||||
|
mSpectralDisplay.waterfallEnabledProperty().get();
|
||||||
|
processingEnabledChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on the JavaFX thread when the spectrum and waterfall are both turned off, or one is turned back on.
|
||||||
|
* Subclasses start or stop their sample source with isProcessingEnabled().
|
||||||
|
*/
|
||||||
|
protected abstract void processingEnabledChanged();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the DFT processor timer.
|
||||||
|
*/
|
||||||
|
protected void startDft()
|
||||||
|
{
|
||||||
|
if(!mDftRunning)
|
||||||
|
{
|
||||||
|
mDftRunning = true;
|
||||||
|
mDftProcessor.clearBuffer();
|
||||||
|
mDftProcessor.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the DFT processor timer and clears the display.
|
||||||
|
*/
|
||||||
|
protected void stopDft()
|
||||||
|
{
|
||||||
|
if(mDftRunning)
|
||||||
|
{
|
||||||
|
mDftRunning = false;
|
||||||
|
mDftProcessor.stop();
|
||||||
|
mDftProcessor.clearBuffer();
|
||||||
|
mSpectralDisplay.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the DFT processor timer is running.
|
||||||
|
*/
|
||||||
|
protected boolean isDftRunning()
|
||||||
|
{
|
||||||
|
return mDftRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the runnable on the JavaFX application thread, ignoring the request after the JavaFX toolkit has exited
|
||||||
|
* during application shutdown.
|
||||||
|
*/
|
||||||
|
protected static void runOnFxThread(Runnable runnable)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Platform.runLater(runnable);
|
||||||
|
}
|
||||||
|
catch(IllegalStateException ise)
|
||||||
|
{
|
||||||
|
//JavaFX toolkit has exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the DFT processing and the display. Subclasses disconnect their sample source first.
|
||||||
|
*/
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
stopDft();
|
||||||
|
mDftProcessor.dispose();
|
||||||
|
mDecibelConverter.dispose();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
mSpectralDisplay.themeProperty().unbindBidirectional(getSharedTheme());
|
||||||
|
|
||||||
|
if(mConfigPanel != null)
|
||||||
|
{
|
||||||
|
mConfigPanel.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
mSpectralDisplay.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse cursor position over the spectral display.
|
||||||
|
*
|
||||||
|
* @param section under the mouse
|
||||||
|
* @param x position in pixels within the section
|
||||||
|
* @param y position in pixels within the section
|
||||||
|
* @param frequency under the mouse in Hertz
|
||||||
|
*/
|
||||||
|
public record DisplayCursor(DisplaySection section, double x, double y, long frequency)
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections of the spectral display.
|
||||||
|
*/
|
||||||
|
public enum DisplaySection
|
||||||
|
{
|
||||||
|
SPECTRUM,
|
||||||
|
WATERFALL;
|
||||||
|
}
|
||||||
213
src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java
Normal file
213
src/main/java/io/github/dsheirer/gui/spectrum/EdgeSlider.java
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.function.DoubleFunction;
|
||||||
|
import javafx.animation.FadeTransition;
|
||||||
|
import javafx.animation.PauseTransition;
|
||||||
|
import javafx.beans.property.DoubleProperty;
|
||||||
|
import javafx.geometry.Orientation;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Slider;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical slider that floats along the right edge of a display. While collapsed it shows as a thin strip with a
|
||||||
|
* marker at the current value. Moving the mouse over the strip expands it into a translucent panel with a title, a
|
||||||
|
* vertical slider and the formatted value. It collapses again shortly after the mouse leaves, unless the slider is
|
||||||
|
* being dragged.
|
||||||
|
*
|
||||||
|
* Place it in a StackPane aligned to the right edge. The slider's minimum is at the bottom.
|
||||||
|
*/
|
||||||
|
public class EdgeSlider extends StackPane
|
||||||
|
{
|
||||||
|
private static final String STYLESHEET = "/spectral_display.css";
|
||||||
|
private static final double COLLAPSED_WIDTH = 14;
|
||||||
|
private static final double STRIP_WIDTH = 4;
|
||||||
|
private static final double MARKER_HEIGHT = 10;
|
||||||
|
private static final double EXPANDED_WIDTH = 64;
|
||||||
|
private static final Duration COLLAPSE_DELAY = Duration.millis(400);
|
||||||
|
private static final Duration FADE_DURATION = Duration.millis(120);
|
||||||
|
|
||||||
|
private final Slider mSlider;
|
||||||
|
private final Label mValueLabel = new Label();
|
||||||
|
private final DoubleFunction<String> mFormatter;
|
||||||
|
private final VBox mExpanded;
|
||||||
|
private final Pane mCollapsed = new Pane();
|
||||||
|
private final Region mStrip = new Region();
|
||||||
|
private final Region mMarker = new Region();
|
||||||
|
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
|
||||||
|
private boolean mIsExpanded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param title shown above the slider
|
||||||
|
* @param tooltip describing the control
|
||||||
|
* @param min value, at the bottom of the slider
|
||||||
|
* @param max value, at the top of the slider
|
||||||
|
* @param step between values
|
||||||
|
* @param formatter for the value shown below the slider
|
||||||
|
*/
|
||||||
|
public EdgeSlider(String title, String tooltip, double min, double max, double step,
|
||||||
|
DoubleFunction<String> formatter)
|
||||||
|
{
|
||||||
|
mFormatter = formatter;
|
||||||
|
URL stylesheet = EdgeSlider.class.getResource(STYLESHEET);
|
||||||
|
|
||||||
|
if(stylesheet != null)
|
||||||
|
{
|
||||||
|
getStylesheets().add(stylesheet.toExternalForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyleClass().add("spectral-edge-slider");
|
||||||
|
setMaxWidth(Region.USE_PREF_SIZE);
|
||||||
|
setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
setPickOnBounds(true);
|
||||||
|
|
||||||
|
mSlider = new Slider(min, max, min);
|
||||||
|
mSlider.setOrientation(Orientation.VERTICAL);
|
||||||
|
mSlider.setMajorTickUnit(step);
|
||||||
|
mSlider.setMinorTickCount(0);
|
||||||
|
mSlider.setBlockIncrement(step);
|
||||||
|
mSlider.setSnapToTicks(true);
|
||||||
|
mSlider.setMaxHeight(Double.MAX_VALUE);
|
||||||
|
VBox.setVgrow(mSlider, Priority.ALWAYS);
|
||||||
|
mSlider.valueProperty().addListener((ob, ov, nv) -> {
|
||||||
|
refreshLabel();
|
||||||
|
requestLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
Label titleLabel = new Label(title);
|
||||||
|
titleLabel.getStyleClass().add("spectral-edge-slider-title");
|
||||||
|
mValueLabel.getStyleClass().add("spectral-edge-slider-value");
|
||||||
|
mExpanded = new VBox(4, titleLabel, mSlider, mValueLabel);
|
||||||
|
mExpanded.setAlignment(Pos.CENTER);
|
||||||
|
mExpanded.getStyleClass().add("spectral-edge-slider-panel");
|
||||||
|
mExpanded.setPrefWidth(EXPANDED_WIDTH);
|
||||||
|
Tooltip.install(mExpanded, new Tooltip(tooltip));
|
||||||
|
|
||||||
|
mStrip.getStyleClass().add("spectral-edge-slider-strip");
|
||||||
|
mMarker.getStyleClass().add("spectral-edge-slider-marker");
|
||||||
|
//Unmanaged so the pane doesn't resize them to their (empty) preferred size - layoutChildren() places them
|
||||||
|
mStrip.setManaged(false);
|
||||||
|
mMarker.setManaged(false);
|
||||||
|
mCollapsed.getChildren().addAll(mStrip, mMarker);
|
||||||
|
mCollapsed.setPrefWidth(COLLAPSED_WIDTH);
|
||||||
|
mCollapsed.setMinWidth(COLLAPSED_WIDTH);
|
||||||
|
|
||||||
|
mCollapseTimer.setOnFinished(e -> {
|
||||||
|
if(!isHover() && !mSlider.isValueChanging() && !mSlider.isPressed())
|
||||||
|
{
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setOnMouseEntered(e -> {
|
||||||
|
mCollapseTimer.stop();
|
||||||
|
setExpanded(true);
|
||||||
|
});
|
||||||
|
setOnMouseExited(e -> mCollapseTimer.playFromStart());
|
||||||
|
mSlider.valueChangingProperty().addListener((ob, ov, nv) -> {
|
||||||
|
if(!nv && !isHover())
|
||||||
|
{
|
||||||
|
mCollapseTimer.playFromStart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
getChildren().add(mCollapsed);
|
||||||
|
refreshLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slider value.
|
||||||
|
*/
|
||||||
|
public DoubleProperty valueProperty()
|
||||||
|
{
|
||||||
|
return mSlider.valueProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the value text, for example when something the formatter depends on changes.
|
||||||
|
*/
|
||||||
|
public void refreshLabel()
|
||||||
|
{
|
||||||
|
mValueLabel.setText(mFormatter != null ? mFormatter.apply(mSlider.getValue()) :
|
||||||
|
String.valueOf(mSlider.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the slider panel is expanded.
|
||||||
|
*/
|
||||||
|
public boolean isExpanded()
|
||||||
|
{
|
||||||
|
return mIsExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands or collapses the slider panel.
|
||||||
|
*/
|
||||||
|
public void setExpanded(boolean expanded)
|
||||||
|
{
|
||||||
|
if(expanded != mIsExpanded)
|
||||||
|
{
|
||||||
|
mIsExpanded = expanded;
|
||||||
|
|
||||||
|
if(expanded)
|
||||||
|
{
|
||||||
|
getChildren().setAll(mExpanded);
|
||||||
|
mExpanded.setOpacity(0);
|
||||||
|
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpanded);
|
||||||
|
fade.setToValue(1.0);
|
||||||
|
fade.play();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
getChildren().setAll(mCollapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void layoutChildren()
|
||||||
|
{
|
||||||
|
super.layoutChildren();
|
||||||
|
|
||||||
|
if(!mIsExpanded)
|
||||||
|
{
|
||||||
|
//Strip along the right side of the hover area, with the marker at the value position
|
||||||
|
double width = mCollapsed.getWidth();
|
||||||
|
double height = mCollapsed.getHeight();
|
||||||
|
double stripX = width - STRIP_WIDTH - 2;
|
||||||
|
mStrip.resizeRelocate(stripX, 0, STRIP_WIDTH, height);
|
||||||
|
|
||||||
|
double range = mSlider.getMax() - mSlider.getMin();
|
||||||
|
double fraction = range > 0 ? (mSlider.getValue() - mSlider.getMin()) / range : 0;
|
||||||
|
double markerY = (1.0 - fraction) * Math.max(0, height - MARKER_HEIGHT);
|
||||||
|
mMarker.resizeRelocate(stripX - 2, markerY, STRIP_WIDTH + 4, MARKER_HEIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size of a display section for rendering.
|
||||||
|
*
|
||||||
|
* @param width of the section in pixels
|
||||||
|
* @param height of the section in pixels
|
||||||
|
* @param plotHeight of the area above the bottom inset where the spectrum trace is drawn. This is the same as the
|
||||||
|
* height for the waterfall.
|
||||||
|
*/
|
||||||
|
public record RenderArea(double width, double height, double plotHeight)
|
||||||
|
{
|
||||||
|
}
|
||||||
1206
src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java
Normal file
1206
src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.dsp.window.WindowType;
|
||||||
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
|
import io.github.dsheirer.spectrum.DFTSize;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.List;
|
||||||
|
import javafx.animation.FadeTransition;
|
||||||
|
import javafx.animation.PauseTransition;
|
||||||
|
import javafx.beans.binding.Bindings;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.geometry.HPos;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.MenuButton;
|
||||||
|
import javafx.scene.control.RadioMenuItem;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.Slider;
|
||||||
|
import javafx.scene.control.SeparatorMenuItem;
|
||||||
|
import javafx.scene.control.ToggleButton;
|
||||||
|
import javafx.scene.control.ToggleGroup;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.ColumnConstraints;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import javafx.util.StringConverter;
|
||||||
|
import jiconfont.icons.font_awesome.FontAwesome;
|
||||||
|
import jiconfont.javafx.IconFontFX;
|
||||||
|
import jiconfont.javafx.IconNode;
|
||||||
|
import org.controlsfx.control.ToggleSwitch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translucent configuration panel that floats over the right side of the spectral display. It shows as a small
|
||||||
|
* settings tab while collapsed and expands while the mouse is over it. The pin button keeps it expanded.
|
||||||
|
*
|
||||||
|
* Controls: tuner selection, spectrum and waterfall on/off switches, waterfall pause, DFT size, window, frame rate,
|
||||||
|
* averaging, peak hold, the settings of each display layer that has settings, and the color theme.
|
||||||
|
*/
|
||||||
|
public class SpectralDisplayConfigPanel extends VBox
|
||||||
|
{
|
||||||
|
private static final String STYLESHEET = "/spectral_display.css";
|
||||||
|
private static final double PANEL_WIDTH = 360;
|
||||||
|
private static final double SWITCH_WIDTH = 40;
|
||||||
|
private static final java.text.DecimalFormat RESOLUTION_HZ_FORMAT = new java.text.DecimalFormat("0.#");
|
||||||
|
private static final java.text.DecimalFormat RESOLUTION_KHZ_FORMAT = new java.text.DecimalFormat("0.##");
|
||||||
|
private static final Duration COLLAPSE_DELAY = Duration.millis(500);
|
||||||
|
private static final Duration FADE_DURATION = Duration.millis(150);
|
||||||
|
|
||||||
|
static
|
||||||
|
{
|
||||||
|
IconFontFX.register(FontAwesome.getIconFont());
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DftSpectralDisplay mHost;
|
||||||
|
private final TunerSpectralDisplay mTunerSpectralDisplay;
|
||||||
|
private final SpectralDisplay mSpectralDisplay;
|
||||||
|
private final BooleanProperty mExpanded = new SimpleBooleanProperty(false);
|
||||||
|
private final BooleanProperty mPinned = new SimpleBooleanProperty(false);
|
||||||
|
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
|
||||||
|
private final IconNode mTabIcon = new IconNode(FontAwesome.SLIDERS);
|
||||||
|
private final VBox mExpandedContent = new VBox();
|
||||||
|
private final MenuButton mTunerMenuButton = new MenuButton();
|
||||||
|
private int mPopupsShowing = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param host display to configure. A tuner display adds a tuner menu to the panel.
|
||||||
|
*/
|
||||||
|
public SpectralDisplayConfigPanel(DftSpectralDisplay host)
|
||||||
|
{
|
||||||
|
mHost = host;
|
||||||
|
mTunerSpectralDisplay = host instanceof TunerSpectralDisplay tunerDisplay ? tunerDisplay : null;
|
||||||
|
mSpectralDisplay = host.getSpectralDisplay();
|
||||||
|
|
||||||
|
URL stylesheet = SpectralDisplayConfigPanel.class.getResource(STYLESHEET);
|
||||||
|
|
||||||
|
if(stylesheet != null)
|
||||||
|
{
|
||||||
|
getStylesheets().add(stylesheet.toExternalForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyleClass().add("spectral-config-panel");
|
||||||
|
setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
|
||||||
|
setFillWidth(true);
|
||||||
|
|
||||||
|
mTabIcon.setIconSize(16);
|
||||||
|
mTabIcon.getStyleClass().add("spectral-config-icon");
|
||||||
|
|
||||||
|
buildExpandedContent();
|
||||||
|
|
||||||
|
mCollapseTimer.setOnFinished(e -> {
|
||||||
|
if(!isHover() && mPopupsShowing == 0 && !mPinned.get())
|
||||||
|
{
|
||||||
|
mExpanded.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setOnMouseEntered(e -> {
|
||||||
|
mCollapseTimer.stop();
|
||||||
|
mExpanded.set(true);
|
||||||
|
});
|
||||||
|
setOnMouseExited(e -> scheduleCollapse());
|
||||||
|
mExpanded.addListener((ob, ov, nv) -> updateExpanded());
|
||||||
|
mPinned.addListener((ob, ov, nv) -> {
|
||||||
|
if(nv)
|
||||||
|
{
|
||||||
|
mExpanded.set(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scheduleCollapse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpanded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the panel expanded when true.
|
||||||
|
*/
|
||||||
|
public BooleanProperty pinnedProperty()
|
||||||
|
{
|
||||||
|
return mPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the panel is expanded.
|
||||||
|
*/
|
||||||
|
public BooleanProperty expandedProperty()
|
||||||
|
{
|
||||||
|
return mExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases resources.
|
||||||
|
*/
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
mCollapseTimer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleCollapse()
|
||||||
|
{
|
||||||
|
if(!mPinned.get())
|
||||||
|
{
|
||||||
|
mCollapseTimer.playFromStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the panel expanded while a combo box, menu or color picker popup is showing, since the mouse leaves the
|
||||||
|
* panel to use the popup.
|
||||||
|
*/
|
||||||
|
private void trackPopup(ReadOnlyBooleanProperty showing)
|
||||||
|
{
|
||||||
|
showing.addListener((ob, ov, nv) -> {
|
||||||
|
mPopupsShowing = Math.max(0, mPopupsShowing + (nv ? 1 : -1));
|
||||||
|
|
||||||
|
if(!nv)
|
||||||
|
{
|
||||||
|
scheduleCollapse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateExpanded()
|
||||||
|
{
|
||||||
|
boolean expanded = mExpanded.get() || mPinned.get();
|
||||||
|
|
||||||
|
if(expanded)
|
||||||
|
{
|
||||||
|
getChildren().setAll(mExpandedContent);
|
||||||
|
getStyleClass().remove("collapsed");
|
||||||
|
setPrefWidth(PANEL_WIDTH);
|
||||||
|
mExpandedContent.setOpacity(0);
|
||||||
|
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpandedContent);
|
||||||
|
fade.setToValue(1.0);
|
||||||
|
fade.play();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
getChildren().setAll(mTabIcon);
|
||||||
|
if(!getStyleClass().contains("collapsed"))
|
||||||
|
{
|
||||||
|
getStyleClass().add("collapsed");
|
||||||
|
}
|
||||||
|
setPrefWidth(Region.USE_COMPUTED_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildExpandedContent()
|
||||||
|
{
|
||||||
|
//Header: icon, title and pin button
|
||||||
|
IconNode headerIcon = new IconNode(FontAwesome.SLIDERS);
|
||||||
|
headerIcon.setIconSize(14);
|
||||||
|
headerIcon.getStyleClass().add("spectral-config-icon");
|
||||||
|
Label title = new Label("Display Settings");
|
||||||
|
title.getStyleClass().add("spectral-config-title");
|
||||||
|
Region spacer = new Region();
|
||||||
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
|
IconNode pinIcon = new IconNode(FontAwesome.THUMB_TACK);
|
||||||
|
pinIcon.setIconSize(12);
|
||||||
|
pinIcon.getStyleClass().add("spectral-config-icon");
|
||||||
|
ToggleButton pinButton = new ToggleButton(null, pinIcon);
|
||||||
|
pinButton.getStyleClass().add("spectral-config-pin");
|
||||||
|
pinButton.selectedProperty().bindBidirectional(mPinned);
|
||||||
|
pinButton.setTooltip(new Tooltip("Keep the settings panel open"));
|
||||||
|
HBox header = new HBox(6, headerIcon, title, spacer, pinButton);
|
||||||
|
header.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
header.setPadding(new Insets(2, 2, 4, 4));
|
||||||
|
|
||||||
|
GridPane grid = new GridPane();
|
||||||
|
grid.setHgap(8);
|
||||||
|
grid.setVgap(5);
|
||||||
|
grid.setPadding(new Insets(0, 8, 6, 6));
|
||||||
|
ColumnConstraints labelColumn = new ColumnConstraints();
|
||||||
|
labelColumn.setHalignment(HPos.RIGHT);
|
||||||
|
//Labels keep their full width - the controls column absorbs any shortfall
|
||||||
|
labelColumn.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
ColumnConstraints controlColumn = new ColumnConstraints();
|
||||||
|
controlColumn.setHgrow(Priority.ALWAYS);
|
||||||
|
controlColumn.setFillWidth(true);
|
||||||
|
grid.getColumnConstraints().addAll(labelColumn, controlColumn);
|
||||||
|
int row = 0;
|
||||||
|
|
||||||
|
//Tuner
|
||||||
|
if(mTunerSpectralDisplay != null)
|
||||||
|
{
|
||||||
|
grid.add(sectionLabel("Source"), 0, row++, 2, 1);
|
||||||
|
mTunerMenuButton.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
mTunerMenuButton.setOnShowing(e -> updateTunerMenu());
|
||||||
|
trackPopup(mTunerMenuButton.showingProperty());
|
||||||
|
mTunerMenuButton.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||||
|
Tuner tuner = mTunerSpectralDisplay.displayedTunerProperty().get();
|
||||||
|
return tuner != null ? tuner.getPreferredName() : "None";
|
||||||
|
}, mTunerSpectralDisplay.displayedTunerProperty()));
|
||||||
|
row = addRow(grid, row, "Tuner", mTunerMenuButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Display sections
|
||||||
|
grid.add(sectionLabel("Display"), 0, row++, 2, 1);
|
||||||
|
ToggleSwitch spectrumSwitch = toggleSwitch("Spectrum", mSpectralDisplay.spectrumEnabledProperty());
|
||||||
|
ToggleSwitch waterfallSwitch = toggleSwitch("Waterfall", mSpectralDisplay.waterfallEnabledProperty());
|
||||||
|
ToggleSwitch pauseSwitch = toggleSwitch("Pause", mSpectralDisplay.waterfallPausedProperty());
|
||||||
|
pauseSwitch.disableProperty().bind(mSpectralDisplay.waterfallEnabledProperty().not());
|
||||||
|
pauseSwitch.setTooltip(new Tooltip("Pause the waterfall"));
|
||||||
|
HBox switches = new HBox(12, labeled(spectrumSwitch), labeled(waterfallSwitch), labeled(pauseSwitch));
|
||||||
|
switches.setAlignment(Pos.CENTER);
|
||||||
|
grid.add(switches, 0, row++, 2, 1);
|
||||||
|
|
||||||
|
ComboBox<SpectralDisplayTheme> theme = comboBox(SpectralDisplayTheme.values());
|
||||||
|
theme.valueProperty().bindBidirectional(mSpectralDisplay.themeProperty());
|
||||||
|
theme.setCellFactory(listView -> new ThemeCell());
|
||||||
|
theme.setButtonCell(new ThemeCell());
|
||||||
|
row = addRow(grid, row, "Theme", theme);
|
||||||
|
|
||||||
|
//Layer settings without a section of their own
|
||||||
|
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
|
||||||
|
{
|
||||||
|
if(layer.getConfigurationTitle() == null)
|
||||||
|
{
|
||||||
|
row = addLayerRows(grid, row, layer.getConfigurationRows());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//DFT
|
||||||
|
grid.add(sectionLabel("FFT"), 0, row++, 2, 1);
|
||||||
|
ComboBox<DFTSize> dftSize = comboBox(DFTSize.values());
|
||||||
|
dftSize.setConverter(dftSizeConverter());
|
||||||
|
//Refresh the resolution shown with each size when the sample rate changes
|
||||||
|
mSpectralDisplay.getModel().sampleRateProperty().addListener((ob, ov, nv) ->
|
||||||
|
dftSize.setConverter(dftSizeConverter()));
|
||||||
|
dftSize.setTooltip(new Tooltip("FFT size and the resulting frequency resolution (bandwidth of each bin)"));
|
||||||
|
dftSize.valueProperty().bindBidirectional(mHost.dftSizeProperty());
|
||||||
|
row = addRow(grid, row, "Size", dftSize);
|
||||||
|
|
||||||
|
ComboBox<WindowType> windowType = comboBox(java.util.Arrays.stream(WindowType.values())
|
||||||
|
.filter(DftSpectralDisplay::isWindowSupported).toArray(WindowType[]::new));
|
||||||
|
windowType.valueProperty().bindBidirectional(mHost.windowTypeProperty());
|
||||||
|
row = addRow(grid, row, "Window", windowType);
|
||||||
|
|
||||||
|
ComboBox<Integer> frameRate = new ComboBox<>();
|
||||||
|
for(int rate: DftSpectralDisplay.FRAME_RATES)
|
||||||
|
{
|
||||||
|
frameRate.getItems().add(rate);
|
||||||
|
}
|
||||||
|
if(!frameRate.getItems().contains(mHost.frameRateProperty().get()))
|
||||||
|
{
|
||||||
|
frameRate.getItems().add(mHost.frameRateProperty().get());
|
||||||
|
frameRate.getItems().sort(Integer::compare);
|
||||||
|
}
|
||||||
|
frameRate.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
frameRate.setConverter(converter(rate -> rate + " fps"));
|
||||||
|
frameRate.setValue(mHost.frameRateProperty().get());
|
||||||
|
frameRate.valueProperty().addListener((ob, ov, nv) -> {
|
||||||
|
if(nv != null)
|
||||||
|
{
|
||||||
|
mHost.frameRateProperty().set(nv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
trackPopup(frameRate.showingProperty());
|
||||||
|
row = addRow(grid, row, "Frame Rate", frameRate);
|
||||||
|
|
||||||
|
Slider averaging = new Slider(SpectralDisplay.AVERAGING_MINIMUM, SpectralDisplay.AVERAGING_MAXIMUM,
|
||||||
|
mSpectralDisplay.averagingProperty().get());
|
||||||
|
averaging.setBlockIncrement(1);
|
||||||
|
averaging.setMajorTickUnit(1);
|
||||||
|
averaging.setMinorTickCount(0);
|
||||||
|
averaging.setSnapToTicks(true);
|
||||||
|
averaging.valueProperty().addListener((ob, ov, nv) ->
|
||||||
|
mSpectralDisplay.averagingProperty().set((int)Math.round(nv.doubleValue())));
|
||||||
|
HBox.setHgrow(averaging, Priority.ALWAYS);
|
||||||
|
Label fast = new Label("Fast");
|
||||||
|
fast.getStyleClass().add("spectral-config-hint");
|
||||||
|
Label slow = new Label("Slow");
|
||||||
|
slow.getStyleClass().add("spectral-config-hint");
|
||||||
|
HBox averagingBox = new HBox(4, fast, averaging, slow);
|
||||||
|
averagingBox.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
Tooltip.install(averagingBox, new Tooltip("Spectrum averaging: signals rise quickly and fade slowly. " +
|
||||||
|
"Slower settings give a steadier noise floor."));
|
||||||
|
row = addRow(grid, row, "Averaging", averagingBox);
|
||||||
|
|
||||||
|
ConfigurationRow peakHold = ConfigurationRow.toggle("Peak Hold", mSpectralDisplay.peakHoldProperty());
|
||||||
|
Tooltip.install(peakHold.control(), new Tooltip("Show a line at the recent peak of each frequency, " +
|
||||||
|
"falling slowly"));
|
||||||
|
row = addRow(grid, row, peakHold.label(), peakHold.control());
|
||||||
|
|
||||||
|
//Layer settings
|
||||||
|
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
|
||||||
|
{
|
||||||
|
List<ConfigurationRow> rows = layer.getConfigurationRows();
|
||||||
|
|
||||||
|
if(layer.getConfigurationTitle() != null && rows != null && !rows.isEmpty())
|
||||||
|
{
|
||||||
|
grid.add(sectionLabel(layer.getConfigurationTitle()), 0, row++, 2, 1);
|
||||||
|
row = addLayerRows(grid, row, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VBox content = new VBox(4, grid);
|
||||||
|
content.setPadding(new Insets(0, 0, 4, 0));
|
||||||
|
|
||||||
|
ScrollPane scrollPane = new ScrollPane(content);
|
||||||
|
scrollPane.setFitToWidth(true);
|
||||||
|
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||||
|
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||||
|
scrollPane.getStyleClass().add("spectral-config-scroll");
|
||||||
|
|
||||||
|
//Limit the panel to the display height and scroll the controls when they don't fit
|
||||||
|
scrollPane.prefViewportHeightProperty().bind(content.heightProperty());
|
||||||
|
scrollPane.maxHeightProperty().bind(Bindings.max(40, mHost.heightProperty()
|
||||||
|
.subtract(header.heightProperty()).subtract(24)));
|
||||||
|
|
||||||
|
mExpandedContent.getChildren().addAll(header, scrollPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converter that labels each DFT size with its bin bandwidth at the current sample rate.
|
||||||
|
*/
|
||||||
|
private StringConverter<DFTSize> dftSizeConverter()
|
||||||
|
{
|
||||||
|
return converter(size -> {
|
||||||
|
int sampleRate = mSpectralDisplay.getModel().getSampleRate();
|
||||||
|
|
||||||
|
if(sampleRate <= 0)
|
||||||
|
{
|
||||||
|
return size.getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return size.getLabel() + " \u00B7 " + formatResolution((double)sampleRate / size.getSize());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a bin bandwidth in Hz or kHz.
|
||||||
|
*/
|
||||||
|
static String formatResolution(double hertz)
|
||||||
|
{
|
||||||
|
if(hertz >= 1000.0)
|
||||||
|
{
|
||||||
|
return RESOLUTION_KHZ_FORMAT.format(hertz / 1000.0) + " kHz/bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RESOLUTION_HZ_FORMAT.format(hertz) + " Hz/bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the tuner menu with the currently available tuners.
|
||||||
|
*/
|
||||||
|
private void updateTunerMenu()
|
||||||
|
{
|
||||||
|
mTunerMenuButton.getItems().clear();
|
||||||
|
ToggleGroup group = new ToggleGroup();
|
||||||
|
Tuner current = mTunerSpectralDisplay.displayedTunerProperty().get();
|
||||||
|
List<Tuner> tuners = mTunerSpectralDisplay.getAvailableTuners();
|
||||||
|
|
||||||
|
for(Tuner tuner: tuners)
|
||||||
|
{
|
||||||
|
RadioMenuItem item = new RadioMenuItem(tuner.getPreferredName());
|
||||||
|
item.setToggleGroup(group);
|
||||||
|
item.setSelected(tuner == current);
|
||||||
|
item.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(tuner));
|
||||||
|
mTunerMenuButton.getItems().add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!tuners.isEmpty())
|
||||||
|
{
|
||||||
|
mTunerMenuButton.getItems().add(new SeparatorMenuItem());
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioMenuItem none = new RadioMenuItem("None");
|
||||||
|
none.setToggleGroup(group);
|
||||||
|
none.setSelected(current == null);
|
||||||
|
none.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(null));
|
||||||
|
mTunerMenuButton.getItems().add(none);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a layer's configuration rows to the grid.
|
||||||
|
* @return next row index
|
||||||
|
*/
|
||||||
|
private int addLayerRows(GridPane grid, int row, List<ConfigurationRow> rows)
|
||||||
|
{
|
||||||
|
if(rows != null)
|
||||||
|
{
|
||||||
|
for(ConfigurationRow configurationRow: rows)
|
||||||
|
{
|
||||||
|
if(configurationRow.control() instanceof ComboBox<?> comboBox)
|
||||||
|
{
|
||||||
|
trackPopup(comboBox.showingProperty());
|
||||||
|
}
|
||||||
|
|
||||||
|
row = addRow(grid, row, configurationRow.label(), configurationRow.control());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int addRow(GridPane grid, int row, String label, Node control)
|
||||||
|
{
|
||||||
|
grid.add(new Label(label), 0, row);
|
||||||
|
grid.add(control, 1, row);
|
||||||
|
GridPane.setHgrow(control, Priority.ALWAYS);
|
||||||
|
return row + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Label sectionLabel(String text)
|
||||||
|
{
|
||||||
|
Label label = new Label(text);
|
||||||
|
label.getStyleClass().add("spectral-config-section");
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places the switch's text in a label to the left of the switch. The switch's own text adds a wide gap.
|
||||||
|
*/
|
||||||
|
private static HBox labeled(ToggleSwitch toggleSwitch)
|
||||||
|
{
|
||||||
|
Label label = new Label(toggleSwitch.getText());
|
||||||
|
label.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
label.disableProperty().bind(toggleSwitch.disabledProperty());
|
||||||
|
label.setOnMouseClicked(e -> {
|
||||||
|
if(!toggleSwitch.isDisabled())
|
||||||
|
{
|
||||||
|
toggleSwitch.setSelected(!toggleSwitch.isSelected());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toggleSwitch.setText(null);
|
||||||
|
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
|
||||||
|
toggleSwitch.setMinWidth(SWITCH_WIDTH);
|
||||||
|
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
|
||||||
|
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
|
||||||
|
HBox box = new HBox(4, label, toggleSwitch);
|
||||||
|
box.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToggleSwitch toggleSwitch(String text, BooleanProperty property)
|
||||||
|
{
|
||||||
|
ToggleSwitch toggleSwitch = new ToggleSwitch(text);
|
||||||
|
toggleSwitch.selectedProperty().bindBidirectional(property);
|
||||||
|
return toggleSwitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private <T> ComboBox<T> comboBox(T... values)
|
||||||
|
{
|
||||||
|
ComboBox<T> comboBox = new ComboBox<>();
|
||||||
|
comboBox.getItems().addAll(values);
|
||||||
|
comboBox.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
trackPopup(comboBox.showingProperty());
|
||||||
|
return comboBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> StringConverter<T> converter(java.util.function.Function<T,String> function)
|
||||||
|
{
|
||||||
|
return new StringConverter<>()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public String toString(T value)
|
||||||
|
{
|
||||||
|
return value != null ? function.apply(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T fromString(String string)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme list cell with a preview swatch: the spectrum background, a channel highlight and the waterfall palette.
|
||||||
|
*/
|
||||||
|
private static class ThemeCell extends javafx.scene.control.ListCell<SpectralDisplayTheme>
|
||||||
|
{
|
||||||
|
private static final double SWATCH_WIDTH = 44;
|
||||||
|
private static final double SWATCH_HEIGHT = 12;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(SpectralDisplayTheme theme, boolean empty)
|
||||||
|
{
|
||||||
|
super.updateItem(theme, empty);
|
||||||
|
|
||||||
|
if(empty || theme == null)
|
||||||
|
{
|
||||||
|
setText(null);
|
||||||
|
setGraphic(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(theme.getLabel());
|
||||||
|
setGraphic(createSwatch(theme));
|
||||||
|
setGraphicTextGap(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Node createSwatch(SpectralDisplayTheme theme)
|
||||||
|
{
|
||||||
|
javafx.scene.canvas.Canvas canvas = new javafx.scene.canvas.Canvas(SWATCH_WIDTH, SWATCH_HEIGHT);
|
||||||
|
javafx.scene.canvas.GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
double half = SWATCH_WIDTH / 2.0;
|
||||||
|
|
||||||
|
//Left half: spectrum background, trace fill and a processing channel highlight
|
||||||
|
gc.setFill(theme.getBackground());
|
||||||
|
gc.fillRect(0, 0, half, SWATCH_HEIGHT);
|
||||||
|
gc.setFill(theme.getChannelProcessing());
|
||||||
|
gc.fillRect(half * 0.55, 0, half * 0.3, SWATCH_HEIGHT);
|
||||||
|
gc.setFill(theme.getGradientBottom());
|
||||||
|
gc.fillRect(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.35);
|
||||||
|
gc.setStroke(theme.getTrace());
|
||||||
|
gc.strokeLine(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.65);
|
||||||
|
|
||||||
|
//Right half: waterfall palette from the noise floor to the strongest signal
|
||||||
|
for(int x = 0; x < half; x++)
|
||||||
|
{
|
||||||
|
int index = (int)Math.round(x / (half - 1) * (WaterfallPalette.SIZE - 1));
|
||||||
|
gc.setFill(WaterfallPalette.toColor(theme.getWaterfallColor(index)));
|
||||||
|
gc.fillRect(half + x, 0, 1, SWATCH_HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.setStroke(javafx.scene.paint.Color.gray(0.5, 0.8));
|
||||||
|
gc.strokeRect(0.5, 0.5, SWATCH_WIDTH - 1, SWATCH_HEIGHT - 1);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.control.MenuItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional layer that draws on the spectral display. The display calls the render methods on the JavaFX application
|
||||||
|
* thread each time it redraws, in the order the layers were added. Override only the methods the layer needs.
|
||||||
|
*
|
||||||
|
* Spectrum drawing order: background color, each layer's background, spectrum trace, each layer's foreground.
|
||||||
|
* Waterfall drawing order: waterfall image, each layer's waterfall overlay.
|
||||||
|
*/
|
||||||
|
public abstract class SpectralDisplayLayer
|
||||||
|
{
|
||||||
|
private SpectralDisplay mSpectralDisplay;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the display when the layer is added.
|
||||||
|
*/
|
||||||
|
void attach(SpectralDisplay spectralDisplay)
|
||||||
|
{
|
||||||
|
mSpectralDisplay = spectralDisplay;
|
||||||
|
attached();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the display when the layer is removed.
|
||||||
|
*/
|
||||||
|
void detach()
|
||||||
|
{
|
||||||
|
detached();
|
||||||
|
mSpectralDisplay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for subclasses after the layer is added to a display.
|
||||||
|
*/
|
||||||
|
protected void attached()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for subclasses before the layer is removed from a display.
|
||||||
|
*/
|
||||||
|
protected void detached()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display this layer is added to, or null.
|
||||||
|
*/
|
||||||
|
protected SpectralDisplay getSpectralDisplay()
|
||||||
|
{
|
||||||
|
return mSpectralDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency model of the display this layer is added to, or null.
|
||||||
|
*/
|
||||||
|
protected SpectralDisplayModel getModel()
|
||||||
|
{
|
||||||
|
return mSpectralDisplay != null ? mSpectralDisplay.getModel() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the display to redraw on the next frame. Safe to call from any thread.
|
||||||
|
*/
|
||||||
|
protected void requestRender()
|
||||||
|
{
|
||||||
|
if(mSpectralDisplay != null)
|
||||||
|
{
|
||||||
|
mSpectralDisplay.requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixels this layer needs along the bottom of the spectrum section, below the spectrum plot. The display reserves
|
||||||
|
* the largest inset that any layer asks for.
|
||||||
|
*/
|
||||||
|
public double getSpectrumBottomInset()
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws behind the spectrum trace.
|
||||||
|
*/
|
||||||
|
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws on top of the spectrum trace.
|
||||||
|
*/
|
||||||
|
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws on top of the waterfall.
|
||||||
|
*/
|
||||||
|
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if this layer draws its own callout in place of the cursor readout for the mouse position, so the
|
||||||
|
* frequency layer skips the readout. The callout should start with the {@link CursorReadout} lines and use its
|
||||||
|
* placement. Called on the JavaFX application thread before the layers draw the section under the mouse.
|
||||||
|
* @param cursor mouse position
|
||||||
|
* @param area of the section under the mouse
|
||||||
|
*/
|
||||||
|
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context menu items for a right-click at the frequency.
|
||||||
|
* @param section that was clicked
|
||||||
|
* @param frequency under the mouse
|
||||||
|
* @return menu items, or an empty list
|
||||||
|
*/
|
||||||
|
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||||
|
{
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title of this layer's section in the configuration panel, or null to place the layer's rows in the panel's
|
||||||
|
* Display section instead of a section of their own.
|
||||||
|
*/
|
||||||
|
public String getConfigurationTitle()
|
||||||
|
{
|
||||||
|
return getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labeled controls for this layer's section of the configuration panel, or an empty list if the layer has no
|
||||||
|
* settings.
|
||||||
|
*/
|
||||||
|
public List<ConfigurationRow> getConfigurationRows()
|
||||||
|
{
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases resources and listeners.
|
||||||
|
*/
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import javafx.beans.property.DoubleProperty;
|
||||||
|
import javafx.beans.property.IntegerProperty;
|
||||||
|
import javafx.beans.property.LongProperty;
|
||||||
|
import javafx.beans.property.SimpleDoubleProperty;
|
||||||
|
import javafx.beans.property.SimpleIntegerProperty;
|
||||||
|
import javafx.beans.property.SimpleLongProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency and zoom state shared by the spectrum, the waterfall and the display layers, with the conversions
|
||||||
|
* between frequency, DFT bin and screen position. Access this model only on the JavaFX application thread.
|
||||||
|
*
|
||||||
|
* DFT bins are in display order (after the half swap) so bin 0 is the lowest frequency and bin (size / 2) is the
|
||||||
|
* center frequency. The center of bin i is at minimum frequency + (i * bin width). The display shows the bins
|
||||||
|
* [zoom offset, zoom offset + visible bin count) and each visible bin covers an equal share of the screen width.
|
||||||
|
*/
|
||||||
|
public class SpectralDisplayModel
|
||||||
|
{
|
||||||
|
public static final int MIN_ZOOM = 0;
|
||||||
|
public static final int MAX_ZOOM = 6;
|
||||||
|
|
||||||
|
private final LongProperty mCenterFrequency = new SimpleLongProperty(0);
|
||||||
|
private final IntegerProperty mSampleRate = new SimpleIntegerProperty(0);
|
||||||
|
private final IntegerProperty mBinCount = new SimpleIntegerProperty(4096);
|
||||||
|
private final IntegerProperty mZoom = new SimpleIntegerProperty(MIN_ZOOM);
|
||||||
|
private final DoubleProperty mZoomOffset = new SimpleDoubleProperty(0);
|
||||||
|
private final DoubleProperty mSampleSize = new SimpleDoubleProperty(16.0);
|
||||||
|
private final DoubleProperty mNoiseBandwidth = new SimpleDoubleProperty(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
*/
|
||||||
|
public SpectralDisplayModel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center frequency of the source in Hertz.
|
||||||
|
*/
|
||||||
|
public LongProperty centerFrequencyProperty()
|
||||||
|
{
|
||||||
|
return mCenterFrequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCenterFrequency()
|
||||||
|
{
|
||||||
|
return mCenterFrequency.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample rate (bandwidth) of the source in Hertz.
|
||||||
|
*/
|
||||||
|
public IntegerProperty sampleRateProperty()
|
||||||
|
{
|
||||||
|
return mSampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSampleRate()
|
||||||
|
{
|
||||||
|
return mSampleRate.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of DFT bins in each result set.
|
||||||
|
*/
|
||||||
|
public IntegerProperty binCountProperty()
|
||||||
|
{
|
||||||
|
return mBinCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBinCount()
|
||||||
|
{
|
||||||
|
return mBinCount.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the bin count and keeps the same frequency at the left edge of the zoom window.
|
||||||
|
*/
|
||||||
|
public void setBinCount(int binCount)
|
||||||
|
{
|
||||||
|
if(binCount > 0 && binCount != getBinCount())
|
||||||
|
{
|
||||||
|
double ratio = getZoomOffset() / (double)getBinCount();
|
||||||
|
mBinCount.set(binCount);
|
||||||
|
setZoomOffset(ratio * binCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level where the zoom multiplier is 2 ^ zoom.
|
||||||
|
*/
|
||||||
|
public IntegerProperty zoomProperty()
|
||||||
|
{
|
||||||
|
return mZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getZoom()
|
||||||
|
{
|
||||||
|
return mZoom.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getZoomMultiplier()
|
||||||
|
{
|
||||||
|
return 1 << getZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the first DFT bin shown at the left edge of the display.
|
||||||
|
*/
|
||||||
|
public DoubleProperty zoomOffsetProperty()
|
||||||
|
{
|
||||||
|
return mZoomOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getZoomOffset()
|
||||||
|
{
|
||||||
|
return mZoomOffset.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the zoom window offset, limited so the zoom window stays inside the DFT bins.
|
||||||
|
*/
|
||||||
|
public void setZoomOffset(double offset)
|
||||||
|
{
|
||||||
|
double max = getBinCount() - getVisibleBinCount();
|
||||||
|
mZoomOffset.set(Math.max(0, Math.min(max, offset)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source sample size in bits, which sets the dB range of the spectrum plot.
|
||||||
|
*/
|
||||||
|
public DoubleProperty sampleSizeProperty()
|
||||||
|
{
|
||||||
|
return mSampleSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalent noise bandwidth of the DFT window, in bins, for dBFS data. When set, the spectrum plot extends
|
||||||
|
* below the sample dynamic range by the DFT processing gain, since the noise in each bin is that much lower than
|
||||||
|
* the total noise. Zero (the default) for data that doesn't include the processing gain.
|
||||||
|
*/
|
||||||
|
public DoubleProperty noiseBandwidthProperty()
|
||||||
|
{
|
||||||
|
return mNoiseBandwidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DFT processing gain in dB: 10 * log10(bin count / window noise bandwidth), or zero when the noise bandwidth is
|
||||||
|
* not set.
|
||||||
|
*/
|
||||||
|
public double getProcessingGain()
|
||||||
|
{
|
||||||
|
double noiseBandwidth = mNoiseBandwidth.get();
|
||||||
|
return noiseBandwidth > 0 ? 10.0 * Math.log10(getBinCount() / noiseBandwidth) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range of the spectrum plot in dB below full scale. The bottom of the plot is at minus this value. The range is
|
||||||
|
* the dynamic range of the source sample size plus the DFT processing gain.
|
||||||
|
*/
|
||||||
|
public double getDynamicRange()
|
||||||
|
{
|
||||||
|
return 20.0 * Math.log10(Math.pow(2.0, mSampleSize.get() - 1.0)) + getProcessingGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if a source frequency and sample rate are set.
|
||||||
|
*/
|
||||||
|
public boolean hasSource()
|
||||||
|
{
|
||||||
|
return getSampleRate() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of DFT bins in the zoom window.
|
||||||
|
*/
|
||||||
|
public int getVisibleBinCount()
|
||||||
|
{
|
||||||
|
return Math.max(1, getBinCount() / getZoomMultiplier());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of each DFT bin in Hertz.
|
||||||
|
*/
|
||||||
|
public double getBinWidth()
|
||||||
|
{
|
||||||
|
return (double)getSampleRate() / (double)getBinCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowest frequency of the source.
|
||||||
|
*/
|
||||||
|
public long getMinFrequency()
|
||||||
|
{
|
||||||
|
return getCenterFrequency() - (getSampleRate() / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highest frequency of the source.
|
||||||
|
*/
|
||||||
|
public long getMaxFrequency()
|
||||||
|
{
|
||||||
|
return getCenterFrequency() + (getSampleRate() / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency at the left edge of the display.
|
||||||
|
*/
|
||||||
|
public double getDisplayMinFrequency()
|
||||||
|
{
|
||||||
|
return getMinFrequency() + ((getZoomOffset() - 0.5) * getBinWidth());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency at the right edge of the display.
|
||||||
|
*/
|
||||||
|
public double getDisplayMaxFrequency()
|
||||||
|
{
|
||||||
|
return getDisplayMinFrequency() + getDisplayBandwidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bandwidth shown across the display, in Hertz.
|
||||||
|
*/
|
||||||
|
public double getDisplayBandwidth()
|
||||||
|
{
|
||||||
|
return getVisibleBinCount() * getBinWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen x position of the frequency.
|
||||||
|
* @param frequency in Hertz
|
||||||
|
* @param width of the display in pixels
|
||||||
|
*/
|
||||||
|
public double getX(double frequency, double width)
|
||||||
|
{
|
||||||
|
double bandwidth = getDisplayBandwidth();
|
||||||
|
|
||||||
|
if(bandwidth <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frequency - getDisplayMinFrequency()) / bandwidth * width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequency at the screen x position.
|
||||||
|
* @param x position in pixels
|
||||||
|
* @param width of the display in pixels
|
||||||
|
*/
|
||||||
|
public long getFrequency(double x, double width)
|
||||||
|
{
|
||||||
|
if(width <= 0)
|
||||||
|
{
|
||||||
|
return getCenterFrequency();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(getDisplayMinFrequency() + (x / width * getDisplayBandwidth()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel width of one DFT bin at the current zoom.
|
||||||
|
*/
|
||||||
|
public double getPixelsPerBin(double width)
|
||||||
|
{
|
||||||
|
return width / getVisibleBinCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the zoom level and keeps the frequency at the same screen position.
|
||||||
|
* @param zoom level, limited to MIN_ZOOM - MAX_ZOOM
|
||||||
|
* @param frequency to hold in place
|
||||||
|
* @param xFraction screen position of the frequency as a fraction (0 - 1.0) of the display width
|
||||||
|
*/
|
||||||
|
public void setZoom(int zoom, long frequency, double xFraction)
|
||||||
|
{
|
||||||
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
||||||
|
|
||||||
|
if(zoom != getZoom())
|
||||||
|
{
|
||||||
|
double bin = hasSource() ? ((frequency - getMinFrequency()) / getBinWidth()) + 0.5 : getBinCount() / 2.0;
|
||||||
|
mZoom.set(zoom);
|
||||||
|
setZoomOffset(bin - (xFraction * getVisibleBinCount()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the zoom to the full source bandwidth.
|
||||||
|
*/
|
||||||
|
public void resetZoom()
|
||||||
|
{
|
||||||
|
mZoom.set(MIN_ZOOM);
|
||||||
|
setZoomOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||||
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
|
import io.github.dsheirer.source.tuner.TunerEvent;
|
||||||
|
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import java.awt.EventQueue;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.embed.swing.JFXPanel;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swing panel that holds the JavaFX tuner spectral display for the main application window.
|
||||||
|
*
|
||||||
|
* The JavaFX display is created on the JavaFX application thread after construction. A tuner requested before the
|
||||||
|
* display exists is shown once the display is ready.
|
||||||
|
*/
|
||||||
|
public class SpectralDisplayPanel extends JFXPanel
|
||||||
|
{
|
||||||
|
private final DiscoveredTunerModel mDiscoveredTunerModel;
|
||||||
|
private TunerSpectralDisplay mTunerSpectralDisplay;
|
||||||
|
private Tuner mPendingTuner;
|
||||||
|
private boolean mDisposed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param playlistManager for the channels
|
||||||
|
* @param discoveredTunerModel for the list of tuners
|
||||||
|
*/
|
||||||
|
public SpectralDisplayPanel(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel)
|
||||||
|
{
|
||||||
|
mDiscoveredTunerModel = discoveredTunerModel;
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, true);
|
||||||
|
display.setTunerSelectionHandler(this::tunerSelected);
|
||||||
|
Scene scene = new Scene(display);
|
||||||
|
ThemeManager.getInstance().register(scene);
|
||||||
|
setScene(scene);
|
||||||
|
|
||||||
|
Tuner pending;
|
||||||
|
|
||||||
|
synchronized(SpectralDisplayPanel.this)
|
||||||
|
{
|
||||||
|
if(mDisposed)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.submit(display::dispose);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mTunerSpectralDisplay = display;
|
||||||
|
pending = mPendingTuner;
|
||||||
|
mPendingTuner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(pending != null)
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.submit(() -> display.showTuner(pending));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a tuner selection in the configuration panel. Selecting a tuner turns the main spectral display on and
|
||||||
|
* selecting no tuner turns it off, the same as the View menu.
|
||||||
|
*/
|
||||||
|
private void tunerSelected(Tuner tuner)
|
||||||
|
{
|
||||||
|
//Broadcast on the Swing event thread, the same as the Swing tuner menu items, since listeners update Swing
|
||||||
|
EventQueue.invokeLater(() -> {
|
||||||
|
if(tuner != null)
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true);
|
||||||
|
mDiscoveredTunerModel.broadcast(new TunerEvent(tuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false);
|
||||||
|
mDiscoveredTunerModel.broadcast(new TunerEvent(getTuner(),
|
||||||
|
TunerEvent.Event.REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the tuner in the display. Can block while the tuner starts its sample stream.
|
||||||
|
*/
|
||||||
|
public void showTuner(Tuner tuner)
|
||||||
|
{
|
||||||
|
TunerSpectralDisplay display;
|
||||||
|
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
if(mTunerSpectralDisplay == null)
|
||||||
|
{
|
||||||
|
mPendingTuner = tuner;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
display = mTunerSpectralDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
display.showTuner(tuner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops showing the current tuner.
|
||||||
|
*/
|
||||||
|
public void clearTuner()
|
||||||
|
{
|
||||||
|
TunerSpectralDisplay display;
|
||||||
|
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
if(mTunerSpectralDisplay == null)
|
||||||
|
{
|
||||||
|
mPendingTuner = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
display = mTunerSpectralDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
display.clearTuner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently displayed tuner, or null.
|
||||||
|
*/
|
||||||
|
public Tuner getTuner()
|
||||||
|
{
|
||||||
|
TunerSpectralDisplay display;
|
||||||
|
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
if(mTunerSpectralDisplay == null)
|
||||||
|
{
|
||||||
|
return mPendingTuner;
|
||||||
|
}
|
||||||
|
|
||||||
|
display = mTunerSpectralDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
return display.getTuner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops showing the tuner and releases resources.
|
||||||
|
*/
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
TunerSpectralDisplay display;
|
||||||
|
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
mDisposed = true;
|
||||||
|
display = mTunerSpectralDisplay;
|
||||||
|
mTunerSpectralDisplay = null;
|
||||||
|
mPendingTuner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(display != null)
|
||||||
|
{
|
||||||
|
display.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color themes for the spectral display. Each theme sets every display color and the waterfall palette, so the user
|
||||||
|
* picks one theme instead of setting each color.
|
||||||
|
*
|
||||||
|
* Color blind safe themes use perceptually uniform waterfall palettes (viridis, cividis, inferno) that keep an even
|
||||||
|
* brightness ramp for each type of color vision deficiency, and Okabe-Ito colors for the channel highlights, which
|
||||||
|
* also differ in brightness so they don't rely on hue alone.
|
||||||
|
*/
|
||||||
|
public enum SpectralDisplayTheme
|
||||||
|
{
|
||||||
|
CLASSIC("Classic", Group.STANDARD,
|
||||||
|
"#000000", "#FFFFFF80", "#00FF0080", "#FFFFFF80", "#C0C0C080", "#FFC800",
|
||||||
|
"#C0C0C03C", "#00FF003C", "#0000FF3C", "#FFFF00",
|
||||||
|
WaterfallPalette.classic()),
|
||||||
|
|
||||||
|
DARK("Dark", Group.STANDARD,
|
||||||
|
"#121417", "#64B5F68C", "#1565C059", "#90CAF9E6", "#8A939ECC", "#FFCA28",
|
||||||
|
"#9AA4B138", "#26A69A59", "#7E57C273", "#FFEE58",
|
||||||
|
WaterfallPalette.gradient("#0B0E24", "#152A6B", "#1F5FA8", "#2BA3B8", "#7BD389", "#F2E35B", "#F28C38",
|
||||||
|
"#E8443A")),
|
||||||
|
|
||||||
|
MEDIUM("Medium", Group.STANDARD,
|
||||||
|
"#3A3F47", "#E0E6EE99", "#8FA8C866", "#F0F4F8E6", "#C4CCD6CC", "#FFB74D",
|
||||||
|
"#C4CCD640", "#66BB6A66", "#42A5F573", "#FFF176",
|
||||||
|
WaterfallPalette.gradient("#2A2F38", "#31527A", "#3C8DAD", "#7BC8A4", "#F2D06B", "#F08A4B", "#E04F5F")),
|
||||||
|
|
||||||
|
LIGHT("Light", Group.STANDARD,
|
||||||
|
"#F7F8FA", "#1E88E559", "#90CAF940", "#1565C0", "#5F6B7AE6", "#D84315",
|
||||||
|
"#607D8B2E", "#43A0474D", "#1E88E54D", "#E65100",
|
||||||
|
WaterfallPalette.gradient("#FFFFFF", "#DCEBF7", "#9CC9E8", "#4F9BD0", "#1F6AA5", "#0B3C73", "#061A40")),
|
||||||
|
|
||||||
|
GREEN_PHOSPHOR("Green Phosphor", Group.MONOCHROME,
|
||||||
|
"#000A00", "#33FF6673", "#00661A59", "#66FF8C", "#2E9E4AD9", "#CCFFCC",
|
||||||
|
"#1F7A354D", "#33FF664D", "#B3FFC666", "#CCFFCC",
|
||||||
|
WaterfallPalette.gradient("#000A00", "#00290A", "#005C1A", "#00A332", "#33FF66", "#CCFFD9")),
|
||||||
|
|
||||||
|
AMBER("Amber", Group.MONOCHROME,
|
||||||
|
"#0D0700", "#FFB00073", "#7A4A0059", "#FFC233", "#B07A10D9", "#FFF0C2",
|
||||||
|
"#8A5A004D", "#FFB00047", "#FFE08A66", "#FFF0C2",
|
||||||
|
WaterfallPalette.gradient("#0D0700", "#2E1A00", "#5C3600", "#A66300", "#FFB000", "#FFE6A6")),
|
||||||
|
|
||||||
|
GRAYSCALE("Grayscale", Group.MONOCHROME,
|
||||||
|
"#000000", "#FFFFFF80", "#60606066", "#FFFFFFD9", "#A0A0A0D9", "#FFFFFF",
|
||||||
|
"#80808040", "#C0C0C04D", "#FFFFFF66", "#FFFFFF",
|
||||||
|
WaterfallPalette.gradient("#000000", "#404040", "#808080", "#C0C0C0", "#FFFFFF")),
|
||||||
|
|
||||||
|
HIGH_CONTRAST_DARK("High Contrast Dark", Group.HIGH_CONTRAST,
|
||||||
|
"#000000", "#FFFFFFBF", "#FFFFFF59", "#FFFFFF", "#FFFFFF", "#FFFF00",
|
||||||
|
"#FFFFFF2E", "#00FF0059", "#00FFFF66", "#FF00FF",
|
||||||
|
WaterfallPalette.gradient("#000000", "#0000FF", "#00FFFF", "#FFFF00", "#FFFFFF")),
|
||||||
|
|
||||||
|
HIGH_CONTRAST_LIGHT("High Contrast Light", Group.HIGH_CONTRAST,
|
||||||
|
"#FFFFFF", "#00000099", "#00000040", "#000000", "#000000", "#D00000",
|
||||||
|
"#0000001F", "#0080004D", "#0000FF4D", "#C000C0",
|
||||||
|
WaterfallPalette.gradient("#FFFFFF", "#FFFF00", "#FF8000", "#C00000", "#000000")),
|
||||||
|
|
||||||
|
VIRIDIS("Viridis (red/green color blind)", Group.COLOR_BLIND,
|
||||||
|
"#0A0A12", "#FDE72573", "#21918C59", "#FDE725", "#A0A0B0D9", "#FFFFFF",
|
||||||
|
"#A0A0A038", "#0072B273", "#E69F0073", "#CC79A7",
|
||||||
|
WaterfallPalette.gradient("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779",
|
||||||
|
"#6DCD59", "#B4DE2C", "#FDE725")),
|
||||||
|
|
||||||
|
CIVIDIS("Cividis (red/green color blind)", Group.COLOR_BLIND,
|
||||||
|
"#00122E", "#FEE83873", "#575D6D66", "#FEE838", "#A9B0BFD9", "#FFFFFF",
|
||||||
|
"#A9B0BF38", "#56B4E966", "#E69F0073", "#CC79A7",
|
||||||
|
WaterfallPalette.gradient("#00224E", "#123570", "#3B496C", "#575D6D", "#707173", "#8A8779", "#A69D75",
|
||||||
|
"#C4B56C", "#E4CF5B", "#FEE838")),
|
||||||
|
|
||||||
|
INFERNO("Inferno (blue/yellow color blind)", Group.COLOR_BLIND,
|
||||||
|
"#000004", "#FCFFA473", "#BC375466", "#FCFFA4", "#B0A8B8D9", "#FFFFFF",
|
||||||
|
"#A0A0A038", "#D55E0073", "#56B4E973", "#FFFFFF",
|
||||||
|
WaterfallPalette.gradient("#000004", "#1B0C41", "#4A0C6B", "#781C6D", "#A52C60", "#CF4446", "#ED6925",
|
||||||
|
"#FB9B06", "#F7D13D", "#FCFFA4"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme groups, for ordering and labeling in the theme selection list.
|
||||||
|
*/
|
||||||
|
public enum Group
|
||||||
|
{
|
||||||
|
STANDARD,
|
||||||
|
MONOCHROME,
|
||||||
|
HIGH_CONTRAST,
|
||||||
|
COLOR_BLIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String mLabel;
|
||||||
|
private final Group mGroup;
|
||||||
|
private final Color mBackground;
|
||||||
|
private final Color mGradientTop;
|
||||||
|
private final Color mGradientBottom;
|
||||||
|
private final Color mTrace;
|
||||||
|
private final Color mLine;
|
||||||
|
private final Color mCursor;
|
||||||
|
private final Color mChannel;
|
||||||
|
private final Color mChannelProcessing;
|
||||||
|
private final Color mChannelSelected;
|
||||||
|
private final Color mCorrection;
|
||||||
|
private final int[] mWaterfallPalette;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a theme. Colors are web color strings with an optional alpha byte (#RRGGBBAA).
|
||||||
|
* @param label to show to the user
|
||||||
|
* @param group the theme belongs to
|
||||||
|
* @param background of the spectrum
|
||||||
|
* @param gradientTop of the spectrum fill, at the middle of the spectrum height
|
||||||
|
* @param gradientBottom of the spectrum fill, at the bottom of the spectrum
|
||||||
|
* @param trace line along the top of the spectrum fill
|
||||||
|
* @param line color of the axis, grid lines and text
|
||||||
|
* @param cursor line and readout color
|
||||||
|
* @param channel fill for channels that are not processing
|
||||||
|
* @param channelProcessing fill for processing channels
|
||||||
|
* @param channelSelected fill for the selected channel
|
||||||
|
* @param correction color of the channel frequency correction marker
|
||||||
|
* @param waterfallPalette 256 ARGB colors from the noise floor to the strongest signal
|
||||||
|
*/
|
||||||
|
SpectralDisplayTheme(String label, Group group, String background, String gradientTop, String gradientBottom,
|
||||||
|
String trace, String line, String cursor, String channel, String channelProcessing,
|
||||||
|
String channelSelected, String correction, int[] waterfallPalette)
|
||||||
|
{
|
||||||
|
mLabel = label;
|
||||||
|
mGroup = group;
|
||||||
|
mBackground = Color.web(background);
|
||||||
|
mGradientTop = Color.web(gradientTop);
|
||||||
|
mGradientBottom = Color.web(gradientBottom);
|
||||||
|
mTrace = Color.web(trace);
|
||||||
|
mLine = Color.web(line);
|
||||||
|
mCursor = Color.web(cursor);
|
||||||
|
mChannel = Color.web(channel);
|
||||||
|
mChannelProcessing = Color.web(channelProcessing);
|
||||||
|
mChannelSelected = Color.web(channelSelected);
|
||||||
|
mCorrection = Color.web(correction);
|
||||||
|
mWaterfallPalette = waterfallPalette;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default theme: the original sdrtrunk spectral display colors.
|
||||||
|
*/
|
||||||
|
public static SpectralDisplayTheme getDefault()
|
||||||
|
{
|
||||||
|
return CLASSIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel()
|
||||||
|
{
|
||||||
|
return mLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Group getGroup()
|
||||||
|
{
|
||||||
|
return mGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getBackground()
|
||||||
|
{
|
||||||
|
return mBackground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background color with the opacity, for readout boxes drawn over the display.
|
||||||
|
*/
|
||||||
|
public Color getBackground(double opacity)
|
||||||
|
{
|
||||||
|
return Color.color(mBackground.getRed(), mBackground.getGreen(), mBackground.getBlue(), opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getGradientTop()
|
||||||
|
{
|
||||||
|
return mGradientTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getGradientBottom()
|
||||||
|
{
|
||||||
|
return mGradientBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getTrace()
|
||||||
|
{
|
||||||
|
return mTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getLine()
|
||||||
|
{
|
||||||
|
return mLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line color with a fraction of its opacity, for grid lines.
|
||||||
|
*/
|
||||||
|
public Color getLine(double opacityFraction)
|
||||||
|
{
|
||||||
|
return Color.color(mLine.getRed(), mLine.getGreen(), mLine.getBlue(), mLine.getOpacity() * opacityFraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getCursor()
|
||||||
|
{
|
||||||
|
return mCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getChannel()
|
||||||
|
{
|
||||||
|
return mChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getChannelProcessing()
|
||||||
|
{
|
||||||
|
return mChannelProcessing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getChannelSelected()
|
||||||
|
{
|
||||||
|
return mChannelSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color getCorrection()
|
||||||
|
{
|
||||||
|
return mCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARGB color for the waterfall intensity index (0 - 255).
|
||||||
|
*/
|
||||||
|
public int getWaterfallColor(int index)
|
||||||
|
{
|
||||||
|
return mWaterfallPalette[index & 0xFF];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waterfall color at the lowest intensity, which is the waterfall background.
|
||||||
|
*/
|
||||||
|
public Color getWaterfallBackground()
|
||||||
|
{
|
||||||
|
return WaterfallPalette.toColor(mWaterfallPalette[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString()
|
||||||
|
{
|
||||||
|
return mLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||||
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
|
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.binding.Bindings;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate window with a tuner spectral display. The window starts with the saved spectral display settings, but
|
||||||
|
* changes made in the window are not saved.
|
||||||
|
*/
|
||||||
|
public class SpectralDisplayWindow
|
||||||
|
{
|
||||||
|
private SpectralDisplayWindow()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a window that shows the tuner. Can be called from any thread once the JavaFX toolkit is running.
|
||||||
|
*/
|
||||||
|
public static void show(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, Tuner tuner)
|
||||||
|
{
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, false);
|
||||||
|
Scene scene = new Scene(display, 1280, 600);
|
||||||
|
ThemeManager.getInstance().register(scene);
|
||||||
|
|
||||||
|
Stage stage = new Stage();
|
||||||
|
stage.setScene(scene);
|
||||||
|
String application = SystemProperties.getInstance().getApplicationName();
|
||||||
|
stage.titleProperty().bind(Bindings.createStringBinding(() -> {
|
||||||
|
Tuner displayed = display.displayedTunerProperty().get();
|
||||||
|
return application + " - " + (displayed != null ? displayed.getPreferredName() : "Spectral Display");
|
||||||
|
}, display.displayedTunerProperty()));
|
||||||
|
stage.setOnHidden(e -> ThreadPool.CACHED.submit(display::dispose));
|
||||||
|
stage.show();
|
||||||
|
|
||||||
|
ThreadPool.CACHED.submit(() -> display.showTuner(tuner));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spectrum trace averaging and peak hold. Thread safe.
|
||||||
|
*
|
||||||
|
* Averaging is an exponential moving average of each bin's linear power with separate rates for rising and falling
|
||||||
|
* values. A falling bin moves 1 / averaging of the way to the new value each frame, and a rising bin moves
|
||||||
|
* ATTACK_RATIO times faster, so signals appear quickly while the noise floor stays steady. Averaging linear power
|
||||||
|
* instead of decibels keeps the result at the true mean power. Averaging decibel values reads about 2.5 dB low for
|
||||||
|
* noise.
|
||||||
|
*
|
||||||
|
* Peak hold keeps the highest recent value of each bin from the unaveraged frames, falling PEAK_DECAY_DB per frame.
|
||||||
|
*/
|
||||||
|
public class SpectrumAverager
|
||||||
|
{
|
||||||
|
public static final int AVERAGING_MINIMUM = 1;
|
||||||
|
public static final int AVERAGING_MAXIMUM = 20;
|
||||||
|
public static final int AVERAGING_DEFAULT = 4;
|
||||||
|
public static final double ATTACK_RATIO = 2.5;
|
||||||
|
public static final float PEAK_DECAY_DB = 0.15f;
|
||||||
|
private static final float MINIMUM_DB = -196.0f;
|
||||||
|
private static final double LN10_OVER_10 = Math.log(10.0) / 10.0;
|
||||||
|
|
||||||
|
private int mAveraging = AVERAGING_DEFAULT;
|
||||||
|
private boolean mPeakHold = false;
|
||||||
|
private boolean mResetPeak = false;
|
||||||
|
//Double precision so the average can fall more than 70 dB in one frame without float rounding error
|
||||||
|
private double[] mAveragedPower;
|
||||||
|
private float[] mPeakBins;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processed spectrum for one DFT frame.
|
||||||
|
* @param average bins in dB after averaging
|
||||||
|
* @param peak hold bins in dB, or null when peak hold is off
|
||||||
|
*/
|
||||||
|
public record Frame(float[] average, float[] peak)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the averaging, from AVERAGING_MINIMUM (no averaging) to AVERAGING_MAXIMUM (slowest).
|
||||||
|
*/
|
||||||
|
public synchronized void setAveraging(int averaging)
|
||||||
|
{
|
||||||
|
mAveraging = Math.max(AVERAGING_MINIMUM, Math.min(AVERAGING_MAXIMUM, averaging));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns peak hold on or off. Turning it on starts from the next frame.
|
||||||
|
*/
|
||||||
|
public synchronized void setPeakHold(boolean peakHold)
|
||||||
|
{
|
||||||
|
mPeakHold = peakHold;
|
||||||
|
mResetPeak = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts the peak hold from the next frame, for example after a frequency change.
|
||||||
|
*/
|
||||||
|
public synchronized void resetPeak()
|
||||||
|
{
|
||||||
|
mResetPeak = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the averaging history and the peak hold.
|
||||||
|
*/
|
||||||
|
public synchronized void reset()
|
||||||
|
{
|
||||||
|
mAveragedPower = null;
|
||||||
|
mPeakBins = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes one frame of DFT bins in dB.
|
||||||
|
*/
|
||||||
|
public synchronized Frame process(float[] bins)
|
||||||
|
{
|
||||||
|
int size = bins.length;
|
||||||
|
double decay = 1.0 / mAveraging;
|
||||||
|
double attack = Math.min(1.0, ATTACK_RATIO * decay);
|
||||||
|
|
||||||
|
if(mAveragedPower == null || mAveragedPower.length != size)
|
||||||
|
{
|
||||||
|
mAveragedPower = new double[size];
|
||||||
|
|
||||||
|
for(int x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
mAveragedPower[x] = toPowerDouble(bins[x]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for(int x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
double power = toPowerDouble(bins[x]);
|
||||||
|
double average = mAveragedPower[x];
|
||||||
|
mAveragedPower[x] = average + ((power - average) * (power > average ? attack : decay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] averaged = new float[size];
|
||||||
|
|
||||||
|
for(int x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
averaged[x] = mAveragedPower[x] > 0 ?
|
||||||
|
Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(mAveragedPower[x]))) : MINIMUM_DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
float[] peak = null;
|
||||||
|
|
||||||
|
if(mPeakHold)
|
||||||
|
{
|
||||||
|
if(mPeakBins == null || mPeakBins.length != size || mResetPeak)
|
||||||
|
{
|
||||||
|
mResetPeak = false;
|
||||||
|
mPeakBins = bins.clone();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for(int x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
mPeakBins[x] = Math.max(bins[x], mPeakBins[x] - PEAK_DECAY_DB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peak = mPeakBins.clone();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mPeakBins = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Frame(averaged, peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts decibels to linear power.
|
||||||
|
*/
|
||||||
|
public static float toPower(float decibels)
|
||||||
|
{
|
||||||
|
return (float)Math.exp(decibels * LN10_OVER_10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double toPowerDouble(float decibels)
|
||||||
|
{
|
||||||
|
return Math.exp(decibels * LN10_OVER_10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts linear power to decibels.
|
||||||
|
*/
|
||||||
|
public static float toDecibels(float power)
|
||||||
|
{
|
||||||
|
return power > 0 ? Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(power))) : MINIMUM_DB;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelModel;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||||
|
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.layer.RadioReferenceLayer;
|
||||||
|
import io.github.dsheirer.playlist.PlaylistManager;
|
||||||
|
import io.github.dsheirer.properties.SystemProperties;
|
||||||
|
import io.github.dsheirer.source.ISourceEventProcessor;
|
||||||
|
import io.github.dsheirer.source.SourceEvent;
|
||||||
|
import io.github.dsheirer.source.tuner.Tuner;
|
||||||
|
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
|
||||||
|
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spectral display for a tuner: the spectral display with the decibel, frequency and playlist channel layers, the DFT
|
||||||
|
* processing for the tuner's sample stream, and the collapsible configuration panel with a tuner menu.
|
||||||
|
*
|
||||||
|
* Construct on the JavaFX application thread. showTuner() and clearTuner() can be called from any thread, but they
|
||||||
|
* can block while the tuner starts or stops its sample stream, so don't call them on the JavaFX application thread.
|
||||||
|
*/
|
||||||
|
public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceEventProcessor
|
||||||
|
{
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(TunerSpectralDisplay.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the main window spectral display shows a tuner. Cleared when the user turns the display off.
|
||||||
|
*/
|
||||||
|
public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled";
|
||||||
|
public static final String SETTINGS_PREFIX = "spectral.display";
|
||||||
|
private static final String CHANNEL_DISPLAY = "channel.display";
|
||||||
|
private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details";
|
||||||
|
private static final String RADIO_REFERENCE_HOVER = "radioreference.hover";
|
||||||
|
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
|
||||||
|
|
||||||
|
private final ChannelLayer mChannelLayer;
|
||||||
|
private final RadioReferenceLayer mRadioReferenceLayer;
|
||||||
|
private final DiscoveredTunerModel mDiscoveredTunerModel;
|
||||||
|
private final Object mTunerLock = new Object();
|
||||||
|
private final ReadOnlyObjectWrapper<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
|
||||||
|
private Consumer<Tuner> mTunerSelectionHandler;
|
||||||
|
private Tuner mTuner;
|
||||||
|
private boolean mProcessing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param playlistManager for the channels
|
||||||
|
* @param discoveredTunerModel for the list of tuners
|
||||||
|
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||||
|
*/
|
||||||
|
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
|
||||||
|
boolean saveSettings)
|
||||||
|
{
|
||||||
|
this(new ChannelLayer(playlistManager != null ? playlistManager.getChannelModel() : null,
|
||||||
|
playlistManager != null ? playlistManager.getChannelProcessingManager() : null),
|
||||||
|
new RadioReferenceLayer(playlistManager != null && playlistManager.getRadioReference() != null ?
|
||||||
|
playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||||
|
playlistManager != null ? new RadioReferencePlaylistActions(playlistManager) : null),
|
||||||
|
discoveredTunerModel, saveSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param channelModel for the channels, or null
|
||||||
|
* @param channelProcessingManager for channel processing events, or null
|
||||||
|
* @param discoveredTunerModel for the list of tuners, or null
|
||||||
|
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||||
|
*/
|
||||||
|
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
|
||||||
|
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||||
|
{
|
||||||
|
this(new ChannelLayer(channelModel, channelProcessingManager), new RadioReferenceLayer(null, null),
|
||||||
|
discoveredTunerModel, saveSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer,
|
||||||
|
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||||
|
{
|
||||||
|
//The radio reference layer draws after the channel layer, so its card is on top
|
||||||
|
super(SETTINGS_PREFIX, saveSettings, channelLayer, radioReferenceLayer);
|
||||||
|
mChannelLayer = channelLayer;
|
||||||
|
mRadioReferenceLayer = radioReferenceLayer;
|
||||||
|
mDiscoveredTunerModel = discoveredTunerModel;
|
||||||
|
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
|
||||||
|
|
||||||
|
SystemProperties properties = SystemProperties.getInstance();
|
||||||
|
mChannelLayer.channelDisplayProperty().set(parse(ChannelLayer.ChannelDisplay.class,
|
||||||
|
properties.get(key(CHANNEL_DISPLAY), ChannelLayer.ChannelDisplay.ALL.name()),
|
||||||
|
ChannelLayer.ChannelDisplay.ALL));
|
||||||
|
mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true));
|
||||||
|
mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name()));
|
||||||
|
mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv));
|
||||||
|
mRadioReferenceLayer.hoverCandidatesProperty().set(properties.get(key(RADIO_REFERENCE_HOVER), true));
|
||||||
|
mRadioReferenceLayer.hoverCandidatesProperty().addListener((ob, ov, nv) -> save(RADIO_REFERENCE_HOVER, nv));
|
||||||
|
|
||||||
|
installConfigPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuner shown in the display, updated on the JavaFX application thread.
|
||||||
|
*/
|
||||||
|
public ReadOnlyObjectProperty<Tuner> displayedTunerProperty()
|
||||||
|
{
|
||||||
|
return mDisplayedTuner.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuners that can be shown.
|
||||||
|
*/
|
||||||
|
public List<Tuner> getAvailableTuners()
|
||||||
|
{
|
||||||
|
List<Tuner> tuners = new ArrayList<>();
|
||||||
|
|
||||||
|
if(mDiscoveredTunerModel != null)
|
||||||
|
{
|
||||||
|
for(DiscoveredTuner discoveredTuner: mDiscoveredTunerModel.getAvailableTuners())
|
||||||
|
{
|
||||||
|
if(discoveredTuner.hasTuner())
|
||||||
|
{
|
||||||
|
tuners.add(discoveredTuner.getTuner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tuners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the handler for tuner selections in the configuration panel. The default handler shows the selected tuner.
|
||||||
|
* @param handler receives the selected tuner, or null when the user selects no tuner. Called on the JavaFX thread.
|
||||||
|
*/
|
||||||
|
public void setTunerSelectionHandler(Consumer<Tuner> handler)
|
||||||
|
{
|
||||||
|
mTunerSelectionHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the configuration panel on the JavaFX application thread when the user selects a tuner, or null.
|
||||||
|
*/
|
||||||
|
void tunerSelected(Tuner tuner)
|
||||||
|
{
|
||||||
|
if(mTunerSelectionHandler != null)
|
||||||
|
{
|
||||||
|
mTunerSelectionHandler.accept(tuner);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.submit(() -> {
|
||||||
|
if(tuner != null)
|
||||||
|
{
|
||||||
|
showTuner(tuner);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
clearTuner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently displayed tuner, or null.
|
||||||
|
*/
|
||||||
|
public Tuner getTuner()
|
||||||
|
{
|
||||||
|
synchronized(mTunerLock)
|
||||||
|
{
|
||||||
|
return mTuner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the tuner in the display and stops showing the previous tuner.
|
||||||
|
*/
|
||||||
|
public void showTuner(Tuner tuner)
|
||||||
|
{
|
||||||
|
synchronized(mTunerLock)
|
||||||
|
{
|
||||||
|
if(tuner == mTuner)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTuner();
|
||||||
|
|
||||||
|
if(tuner == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mTuner = tuner;
|
||||||
|
tuner.getTunerController().addListener(this);
|
||||||
|
long frequency = tuner.getTunerController().getFrequency();
|
||||||
|
int sampleRate = (int)tuner.getTunerController().getSampleRate();
|
||||||
|
double sampleSize = tuner.getSampleSize();
|
||||||
|
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
if(getTuner() == tuner)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||||
|
model.sampleSizeProperty().set(sampleSize);
|
||||||
|
model.centerFrequencyProperty().set(frequency);
|
||||||
|
model.sampleRateProperty().set(sampleRate);
|
||||||
|
getSpectralDisplay().messageProperty().set(null);
|
||||||
|
mDisplayedTuner.set(tuner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops showing the current tuner.
|
||||||
|
*/
|
||||||
|
public void clearTuner()
|
||||||
|
{
|
||||||
|
synchronized(mTunerLock)
|
||||||
|
{
|
||||||
|
Tuner tuner = mTuner;
|
||||||
|
|
||||||
|
if(tuner != null)
|
||||||
|
{
|
||||||
|
stopProcessing();
|
||||||
|
tuner.getTunerController().removeListener(this);
|
||||||
|
mTuner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpectralDisplay().clear();
|
||||||
|
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
if(getTuner() == null)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||||
|
model.sampleRateProperty().set(0);
|
||||||
|
model.centerFrequencyProperty().set(0);
|
||||||
|
model.resetZoom();
|
||||||
|
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
|
||||||
|
mDisplayedTuner.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void processingEnabledChanged()
|
||||||
|
{
|
||||||
|
ThreadPool.CACHED.submit(() -> {
|
||||||
|
synchronized(mTunerLock)
|
||||||
|
{
|
||||||
|
updateProcessing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or stops processing the tuner sample stream. Hold the tuner lock when calling.
|
||||||
|
*/
|
||||||
|
private void updateProcessing()
|
||||||
|
{
|
||||||
|
if(mTuner != null && isProcessingEnabled())
|
||||||
|
{
|
||||||
|
startProcessing();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stopProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startProcessing()
|
||||||
|
{
|
||||||
|
if(!mProcessing && mTuner != null)
|
||||||
|
{
|
||||||
|
mProcessing = true;
|
||||||
|
startDft();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mTuner.getTunerController().addBufferListener(getDftProcessor());
|
||||||
|
}
|
||||||
|
catch(Exception e)
|
||||||
|
{
|
||||||
|
LOGGER.error("Error registering spectral display for tuner sample buffers", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopProcessing()
|
||||||
|
{
|
||||||
|
if(mProcessing)
|
||||||
|
{
|
||||||
|
mProcessing = false;
|
||||||
|
|
||||||
|
if(mTuner != null)
|
||||||
|
{
|
||||||
|
mTuner.getTunerController().removeBufferListener(getDftProcessor());
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives frequency and sample rate changes from the tuner.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void process(SourceEvent event)
|
||||||
|
{
|
||||||
|
switch(event.getEvent())
|
||||||
|
{
|
||||||
|
case NOTIFICATION_FREQUENCY_CHANGE ->
|
||||||
|
{
|
||||||
|
long frequency = event.getValue().longValue();
|
||||||
|
runOnFxThread(() -> getSpectralDisplay().getModel().centerFrequencyProperty().set(frequency));
|
||||||
|
}
|
||||||
|
case NOTIFICATION_SAMPLE_RATE_CHANGE ->
|
||||||
|
{
|
||||||
|
int sampleRate = event.getValue().intValue();
|
||||||
|
runOnFxThread(() -> getSpectralDisplay().getModel().sampleRateProperty().set(sampleRate));
|
||||||
|
}
|
||||||
|
default ->
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops showing the tuner and releases resources. Call from a thread other than the JavaFX application thread
|
||||||
|
* when a tuner is showing, since stopping the sample stream can block.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
clearTuner();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum;
|
||||||
|
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds waterfall color palettes that map an intensity index (0 - 255) to an opaque ARGB color.
|
||||||
|
*/
|
||||||
|
public class WaterfallPalette
|
||||||
|
{
|
||||||
|
public static final int SIZE = 256;
|
||||||
|
|
||||||
|
private WaterfallPalette()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette of the original Swing waterfall: dark blue noise floor, a blue to yellow ramp, yellow fading to orange,
|
||||||
|
* then red.
|
||||||
|
*/
|
||||||
|
public static int[] classic()
|
||||||
|
{
|
||||||
|
int[] palette = new int[SIZE];
|
||||||
|
|
||||||
|
for(int x = 0; x < 16; x++)
|
||||||
|
{
|
||||||
|
palette[x] = argb(0, 0, 127);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int x = 16; x < 32; x++)
|
||||||
|
{
|
||||||
|
palette[x] = argb(0, 0, 191);
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = 0;
|
||||||
|
int g = 0;
|
||||||
|
int b = 191;
|
||||||
|
|
||||||
|
for(int x = 32; x < 60; x++)
|
||||||
|
{
|
||||||
|
palette[x] = argb(r, g, b);
|
||||||
|
r += 9;
|
||||||
|
g += 9;
|
||||||
|
b -= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
g = 255;
|
||||||
|
|
||||||
|
for(int x = 60; x < 188; x++)
|
||||||
|
{
|
||||||
|
palette[x] = argb(255, g, 0);
|
||||||
|
g -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int x = 188; x < SIZE; x++)
|
||||||
|
{
|
||||||
|
palette[x] = argb(255, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette that blends linearly between evenly spaced colors.
|
||||||
|
* @param colors web color strings (for example #440154), lowest intensity first
|
||||||
|
*/
|
||||||
|
public static int[] gradient(String... colors)
|
||||||
|
{
|
||||||
|
if(colors.length < 2)
|
||||||
|
{
|
||||||
|
throw new IllegalArgumentException("A gradient palette needs at least two colors");
|
||||||
|
}
|
||||||
|
|
||||||
|
Color[] stops = new Color[colors.length];
|
||||||
|
|
||||||
|
for(int x = 0; x < colors.length; x++)
|
||||||
|
{
|
||||||
|
stops[x] = Color.web(colors[x]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] palette = new int[SIZE];
|
||||||
|
int segments = stops.length - 1;
|
||||||
|
|
||||||
|
for(int x = 0; x < SIZE; x++)
|
||||||
|
{
|
||||||
|
double position = (double)x / (SIZE - 1) * segments;
|
||||||
|
int segment = Math.min(segments - 1, (int)Math.floor(position));
|
||||||
|
Color color = stops[segment].interpolate(stops[segment + 1], position - segment);
|
||||||
|
palette[x] = argb((int)Math.round(color.getRed() * 255), (int)Math.round(color.getGreen() * 255),
|
||||||
|
(int)Math.round(color.getBlue() * 255));
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaFX color for an ARGB palette entry.
|
||||||
|
*/
|
||||||
|
public static Color toColor(int argb)
|
||||||
|
{
|
||||||
|
return Color.rgb((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int argb(int r, int g, int b)
|
||||||
|
{
|
||||||
|
return 0xFF000000 | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,801 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum.layer;
|
||||||
|
|
||||||
|
import io.github.dsheirer.controller.channel.Channel;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelEvent;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelException;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelModel;
|
||||||
|
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||||
|
import io.github.dsheirer.eventbus.MyEventBus;
|
||||||
|
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
|
||||||
|
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
|
||||||
|
import io.github.dsheirer.gui.spectrum.DisplayCursor;
|
||||||
|
import io.github.dsheirer.gui.spectrum.DisplaySection;
|
||||||
|
import io.github.dsheirer.gui.spectrum.RenderArea;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
|
import io.github.dsheirer.sample.Listener;
|
||||||
|
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||||
|
import io.github.dsheirer.util.ThreadPool;
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.ConcurrentModificationException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.MenuItem;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.FontWeight;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextAlignment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist channel layer. Draws each playlist channel, enabled (processing) or disabled, and each active traffic
|
||||||
|
* channel (tracked from the channel processing start and stop events) as a translucent box at its frequency and bandwidth, labeled with the system, site, channel name and
|
||||||
|
* decoder. Processing and selected channels use their own colors. When the decoder applies a frequency correction,
|
||||||
|
* a bracket at the bottom of the box shows the corrected channel position and the PPM error.
|
||||||
|
*
|
||||||
|
* When the zoom level is too low for a channel's labels to fit in its box, moving the mouse over the channel shows a
|
||||||
|
* popout card near the bottom of the spectrum (or the waterfall) with the channel name, system, site, decoder,
|
||||||
|
* frequency and state. The popout can be turned off with the hover details setting.
|
||||||
|
*
|
||||||
|
* Right-clicking a channel adds a menu item to view or edit the channel in the playlist editor.
|
||||||
|
*/
|
||||||
|
public class ChannelLayer extends SpectralDisplayLayer
|
||||||
|
{
|
||||||
|
private static final Font FONT = Font.font(11);
|
||||||
|
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.0");
|
||||||
|
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||||
|
private static final Font POPOUT_TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
|
||||||
|
private static final double MIN_LABEL_BOX_WIDTH = 12;
|
||||||
|
private static final double POPOUT_MIN_HIT_WIDTH = 10;
|
||||||
|
private static final double POPOUT_PADDING = 6;
|
||||||
|
private static final double POPOUT_GAP = 5;
|
||||||
|
private static final double POPOUT_SWATCH = 8;
|
||||||
|
private static final int POPOUT_MAX_CHANNELS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which channels the layer shows.
|
||||||
|
*/
|
||||||
|
public enum ChannelDisplay
|
||||||
|
{
|
||||||
|
ALL("All Channels"),
|
||||||
|
ENABLED("Enabled Channels"),
|
||||||
|
NONE("No Channels");
|
||||||
|
|
||||||
|
private final String mLabel;
|
||||||
|
|
||||||
|
ChannelDisplay(String label)
|
||||||
|
{
|
||||||
|
mLabel = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString()
|
||||||
|
{
|
||||||
|
return mLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ChannelModel mChannelModel;
|
||||||
|
private final ChannelProcessingManager mChannelProcessingManager;
|
||||||
|
private final Listener<ChannelEvent> mChannelEventListener = this::receive;
|
||||||
|
//Active traffic channels. Traffic channel managers keep their temporary channels out of the channel model, so
|
||||||
|
//they are tracked from the processing start and stop events.
|
||||||
|
private final Set<Channel> mTrafficChannels = ConcurrentHashMap.newKeySet();
|
||||||
|
private final ObjectProperty<ChannelDisplay> mChannelDisplay = new SimpleObjectProperty<>(ChannelDisplay.ALL);
|
||||||
|
private final BooleanProperty mHoverDetails = new SimpleBooleanProperty(true);
|
||||||
|
private final Text mMeasure = new Text();
|
||||||
|
private final double mTextHeight;
|
||||||
|
private volatile boolean mChannelsChanged = true;
|
||||||
|
private List<Channel> mChannels = new ArrayList<>();
|
||||||
|
private long mQueryMinimum;
|
||||||
|
private long mQueryMaximum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
* @param channelModel for the playlist channels and the active traffic channels
|
||||||
|
* @param channelProcessingManager for channel processing start and stop events
|
||||||
|
*/
|
||||||
|
public ChannelLayer(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager)
|
||||||
|
{
|
||||||
|
mChannelModel = channelModel;
|
||||||
|
mChannelProcessingManager = channelProcessingManager;
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
mMeasure.setText("Ag");
|
||||||
|
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||||
|
mChannelDisplay.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
mHoverDetails.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
|
||||||
|
if(mChannelModel != null)
|
||||||
|
{
|
||||||
|
mChannelModel.addListener(mChannelEventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mChannelProcessingManager != null)
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.addChannelEventListener(mChannelEventListener);
|
||||||
|
|
||||||
|
//Traffic channels that were already running before this layer was created
|
||||||
|
for(Channel channel: mChannelProcessingManager.getProcessingChannels())
|
||||||
|
{
|
||||||
|
if(channel.isTrafficChannel())
|
||||||
|
{
|
||||||
|
mTrafficChannels.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks active traffic channels and refreshes the channels on any channel change.
|
||||||
|
*/
|
||||||
|
private void receive(ChannelEvent event)
|
||||||
|
{
|
||||||
|
Channel channel = event.getChannel();
|
||||||
|
|
||||||
|
if(channel != null && channel.isTrafficChannel())
|
||||||
|
{
|
||||||
|
switch(event.getEvent())
|
||||||
|
{
|
||||||
|
case NOTIFICATION_PROCESSING_START -> mTrafficChannels.add(channel);
|
||||||
|
case NOTIFICATION_PROCESSING_STOP, NOTIFICATION_PROCESSING_START_REJECTED, NOTIFICATION_DELETE ->
|
||||||
|
mTrafficChannels.remove(channel);
|
||||||
|
default ->
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channelsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which channels to show.
|
||||||
|
*/
|
||||||
|
public ObjectProperty<ChannelDisplay> channelDisplayProperty()
|
||||||
|
{
|
||||||
|
return mChannelDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose()
|
||||||
|
{
|
||||||
|
if(mChannelModel != null)
|
||||||
|
{
|
||||||
|
mChannelModel.removeListener(mChannelEventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mChannelProcessingManager != null)
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.removeChannelEventListener(mChannelEventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
mChannels.clear();
|
||||||
|
mTrafficChannels.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void channelsChanged()
|
||||||
|
{
|
||||||
|
mChannelsChanged = true;
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getConfigurationTitle()
|
||||||
|
{
|
||||||
|
return "Channels";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ConfigurationRow> getConfigurationRows()
|
||||||
|
{
|
||||||
|
ComboBox<ChannelDisplay> comboBox = new ComboBox<>();
|
||||||
|
comboBox.getItems().addAll(ChannelDisplay.values());
|
||||||
|
comboBox.valueProperty().bindBidirectional(mChannelDisplay);
|
||||||
|
comboBox.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
ConfigurationRow details = ConfigurationRow.toggle("Hover Details", mHoverDetails);
|
||||||
|
Tooltip.install(details.control(), new Tooltip("Show channel details under the mouse when the channel " +
|
||||||
|
"labels don't fit at the current zoom"));
|
||||||
|
return List.of(new ConfigurationRow("Show", comboBox), details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a popout card with the details of the channels under the mouse when their labels don't fit.
|
||||||
|
*/
|
||||||
|
public BooleanProperty hoverDetailsProperty()
|
||||||
|
{
|
||||||
|
return mHoverDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels that overlap the source bandwidth, refreshed when the channels or the source frequency change.
|
||||||
|
*/
|
||||||
|
private List<Channel> getChannels(SpectralDisplayModel model)
|
||||||
|
{
|
||||||
|
long minimum = model.getMinFrequency();
|
||||||
|
long maximum = model.getMaxFrequency();
|
||||||
|
|
||||||
|
if(mChannelsChanged || minimum != mQueryMinimum || maximum != mQueryMaximum)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mChannelsChanged = false;
|
||||||
|
List<Channel> channels = mChannelModel != null ?
|
||||||
|
mChannelModel.getChannelsInFrequencyRange(minimum, maximum) : new ArrayList<>();
|
||||||
|
|
||||||
|
for(Channel trafficChannel: mTrafficChannels)
|
||||||
|
{
|
||||||
|
if(!channels.contains(trafficChannel) && trafficChannel.isWithin(minimum, maximum))
|
||||||
|
{
|
||||||
|
channels.add(trafficChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mChannels = channels;
|
||||||
|
mQueryMinimum = minimum;
|
||||||
|
mQueryMaximum = maximum;
|
||||||
|
}
|
||||||
|
catch(ConcurrentModificationException cme)
|
||||||
|
{
|
||||||
|
//The channel lists changed while reading them - try again on the next frame
|
||||||
|
mChannelsChanged = true;
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isShown(Channel channel)
|
||||||
|
{
|
||||||
|
return switch(mChannelDisplay.get())
|
||||||
|
{
|
||||||
|
case ALL -> true;
|
||||||
|
case ENABLED -> channel.isProcessing();
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
|
||||||
|
for(Channel channel: getChannels(model))
|
||||||
|
{
|
||||||
|
if(!isShown(channel))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color color = channel.isSelected() ? theme.getChannelSelected() :
|
||||||
|
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
|
||||||
|
gc.setFill(color);
|
||||||
|
|
||||||
|
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||||
|
{
|
||||||
|
Box box = getBox(model, tunerChannel, area);
|
||||||
|
|
||||||
|
if(box != null)
|
||||||
|
{
|
||||||
|
gc.fillRect(box.x(), 0, box.width(), area.plotHeight());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
Color line = theme.getLine();
|
||||||
|
gc.setFont(FONT);
|
||||||
|
gc.setTextBaseline(VPos.TOP);
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
|
||||||
|
for(Channel channel: getChannels(model))
|
||||||
|
{
|
||||||
|
if(!isShown(channel))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||||
|
{
|
||||||
|
Box box = getBox(model, tunerChannel, area);
|
||||||
|
|
||||||
|
if(box == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double center = Math.floor(box.center()) + 0.5;
|
||||||
|
gc.setStroke(line);
|
||||||
|
gc.strokeLine(center, area.plotHeight() * 0.65, center, area.plotHeight() - 1);
|
||||||
|
|
||||||
|
if(box.width() >= MIN_LABEL_BOX_WIDTH)
|
||||||
|
{
|
||||||
|
gc.save();
|
||||||
|
gc.beginPath();
|
||||||
|
gc.rect(box.x(), 0, box.width(), area.plotHeight());
|
||||||
|
gc.clip();
|
||||||
|
gc.setFill(line);
|
||||||
|
double y = 1;
|
||||||
|
y = drawLabel(gc, channel.hasSystem() ? channel.getSystem() : null, box, y);
|
||||||
|
y = drawLabel(gc, channel.hasSite() ? channel.getSite() : null, box, y);
|
||||||
|
y = drawLabel(gc, channel.getName(), box, y);
|
||||||
|
drawLabel(gc, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), box, y);
|
||||||
|
gc.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
int correction = channel.getChannelFrequencyCorrection();
|
||||||
|
|
||||||
|
if(correction != 0)
|
||||||
|
{
|
||||||
|
drawCorrection(gc, model, area, tunerChannel, box, correction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPopout(gc, area, DisplaySection.SPECTRUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
renderPopout(gc, area, DisplaySection.WATERFALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a card near the bottom of the section under the mouse with the details of each channel under the mouse
|
||||||
|
* whose labels don't fit in its box at the current zoom. The card moves to the top of the section when the mouse
|
||||||
|
* is over the bottom of the section.
|
||||||
|
*/
|
||||||
|
private void renderPopout(GraphicsContext gc, RenderArea area, DisplaySection section)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
DisplayCursor cursor = getSpectralDisplay().getDisplayCursor();
|
||||||
|
|
||||||
|
if(!mHoverDetails.get() || model == null || !model.hasSource() || cursor == null ||
|
||||||
|
cursor.section() != section || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PopoutEntry> entries = getPopoutEntries(model, area, cursor.x());
|
||||||
|
|
||||||
|
if(entries.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
int shown = Math.min(entries.size(), POPOUT_MAX_CHANNELS);
|
||||||
|
String more = entries.size() > shown ? "+ " + (entries.size() - shown) + " more" : null;
|
||||||
|
|
||||||
|
//Measure the card
|
||||||
|
double textWidth = 0;
|
||||||
|
int lineCount = 0;
|
||||||
|
|
||||||
|
for(int x = 0; x < shown; x++)
|
||||||
|
{
|
||||||
|
PopoutEntry entry = entries.get(x);
|
||||||
|
textWidth = Math.max(textWidth, measure(entry.title(), POPOUT_TITLE_FONT) + POPOUT_SWATCH + 5);
|
||||||
|
|
||||||
|
for(String line: entry.details())
|
||||||
|
{
|
||||||
|
textWidth = Math.max(textWidth, measure(line, FONT));
|
||||||
|
}
|
||||||
|
|
||||||
|
lineCount += 1 + entry.details().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(more != null)
|
||||||
|
{
|
||||||
|
textWidth = Math.max(textWidth, measure(more, FONT));
|
||||||
|
lineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
double width = textWidth + (POPOUT_PADDING * 2);
|
||||||
|
double height = (lineCount * mTextHeight) + ((shown - 1) * POPOUT_GAP) + (POPOUT_PADDING * 2);
|
||||||
|
double bottom = section == DisplaySection.SPECTRUM ? area.plotHeight() : area.height();
|
||||||
|
double x = Math.max(4, Math.min(area.width() - width - 4, cursor.x() - (width / 2.0)));
|
||||||
|
double y = bottom - height - 6;
|
||||||
|
|
||||||
|
//Keep the card out from under the mouse
|
||||||
|
if(cursor.y() >= y - 4)
|
||||||
|
{
|
||||||
|
y = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(y < 0 || width > area.width())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.setFill(theme.getBackground(0.88));
|
||||||
|
gc.fillRoundRect(x, y, width, height, 8, 8);
|
||||||
|
gc.setStroke(opaque(theme.getLine()));
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8);
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.setTextBaseline(VPos.TOP);
|
||||||
|
|
||||||
|
double lineY = y + POPOUT_PADDING;
|
||||||
|
double textX = x + POPOUT_PADDING;
|
||||||
|
|
||||||
|
for(int index = 0; index < shown; index++)
|
||||||
|
{
|
||||||
|
PopoutEntry entry = entries.get(index);
|
||||||
|
double swatchY = lineY + ((mTextHeight - POPOUT_SWATCH) / 2.0);
|
||||||
|
gc.setFill(opaque(entry.color()));
|
||||||
|
gc.fillRect(textX, swatchY, POPOUT_SWATCH, POPOUT_SWATCH);
|
||||||
|
gc.setFill(theme.getCursor());
|
||||||
|
gc.setFont(POPOUT_TITLE_FONT);
|
||||||
|
gc.fillText(entry.title(), textX + POPOUT_SWATCH + 5, lineY);
|
||||||
|
lineY += mTextHeight;
|
||||||
|
gc.setFill(opaque(theme.getLine()));
|
||||||
|
gc.setFont(FONT);
|
||||||
|
|
||||||
|
for(String line: entry.details())
|
||||||
|
{
|
||||||
|
gc.fillText(line, textX, lineY);
|
||||||
|
lineY += mTextHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineY += POPOUT_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(more != null)
|
||||||
|
{
|
||||||
|
gc.fillText(more, textX, lineY - POPOUT_GAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels under the screen x position whose labels don't fit in the channel box. The hit area of a narrow
|
||||||
|
* channel is widened so it can be found with the mouse.
|
||||||
|
*/
|
||||||
|
private List<PopoutEntry> getPopoutEntries(SpectralDisplayModel model, RenderArea area, double cursorX)
|
||||||
|
{
|
||||||
|
List<PopoutEntry> entries = new ArrayList<>();
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
|
||||||
|
for(Channel channel: getChannels(model))
|
||||||
|
{
|
||||||
|
if(!isShown(channel))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||||
|
{
|
||||||
|
Box box = getBox(model, tunerChannel, area);
|
||||||
|
|
||||||
|
if(box == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double hitWidth = Math.max(POPOUT_MIN_HIT_WIDTH, box.width());
|
||||||
|
double hitStart = box.center() - (hitWidth / 2.0);
|
||||||
|
|
||||||
|
if(cursorX < hitStart || cursorX > hitStart + hitWidth || labelsFit(channel, box))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> details = new ArrayList<>();
|
||||||
|
String systemSite = join(channel.hasSystem() ? channel.getSystem() : null,
|
||||||
|
channel.hasSite() ? channel.getSite() : null);
|
||||||
|
|
||||||
|
if(systemSite != null)
|
||||||
|
{
|
||||||
|
details.add(systemSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
String state = channel.isProcessing() ? "Enabled" : "Disabled";
|
||||||
|
|
||||||
|
if(channel.isSelected())
|
||||||
|
{
|
||||||
|
state += ", Selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
details.add(join(channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(),
|
||||||
|
FREQUENCY_FORMAT.format(tunerChannel.getFrequency() / 1E6) + " MHz", state));
|
||||||
|
|
||||||
|
Color color = channel.isSelected() ? theme.getChannelSelected() :
|
||||||
|
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
|
||||||
|
entries.add(new PopoutEntry(channel.getName() != null ? channel.getName() : "(unnamed)", details,
|
||||||
|
color));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if all of the channel labels fit inside the channel box without clipping.
|
||||||
|
*/
|
||||||
|
private boolean labelsFit(Channel channel, Box box)
|
||||||
|
{
|
||||||
|
if(box.width() < MIN_LABEL_BOX_WIDTH)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] labels = {channel.hasSystem() ? channel.getSystem() : null,
|
||||||
|
channel.hasSite() ? channel.getSite() : null, channel.getName(),
|
||||||
|
channel.getDecodeConfiguration().getDecoderType().getShortDisplayString()};
|
||||||
|
|
||||||
|
for(String label: labels)
|
||||||
|
{
|
||||||
|
if(label != null && !label.isEmpty() && measure(label, FONT) > box.width())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double measure(String text, Font font)
|
||||||
|
{
|
||||||
|
mMeasure.setFont(font);
|
||||||
|
mMeasure.setText(text);
|
||||||
|
double width = mMeasure.getLayoutBounds().getWidth();
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the non-empty values with a separator, or returns null when there are none.
|
||||||
|
*/
|
||||||
|
private static String join(String... values)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
for(String value: values)
|
||||||
|
{
|
||||||
|
if(value != null && !value.isEmpty())
|
||||||
|
{
|
||||||
|
if(!sb.isEmpty())
|
||||||
|
{
|
||||||
|
sb.append(" \u00B7 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.isEmpty() ? null : sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color opaque(Color color)
|
||||||
|
{
|
||||||
|
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the label centered in the box, or left aligned and clipped when it is wider than the box.
|
||||||
|
* @return y position for the next label
|
||||||
|
*/
|
||||||
|
private double drawLabel(GraphicsContext gc, String text, Box box, double y)
|
||||||
|
{
|
||||||
|
if(text != null && !text.isEmpty())
|
||||||
|
{
|
||||||
|
mMeasure.setText(text);
|
||||||
|
double width = mMeasure.getLayoutBounds().getWidth();
|
||||||
|
|
||||||
|
if(width > box.width())
|
||||||
|
{
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.fillText(text, box.x() + 1, y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
gc.setTextAlign(TextAlignment.CENTER);
|
||||||
|
gc.fillText(text, box.center(), y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return y + mTextHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a bracket at the bottom of the channel at the corrected frequency, labeled with the PPM error.
|
||||||
|
*/
|
||||||
|
private void drawCorrection(GraphicsContext gc, SpectralDisplayModel model, RenderArea area,
|
||||||
|
TunerChannel tunerChannel, Box box, int correction)
|
||||||
|
{
|
||||||
|
double top = area.plotHeight() * 0.88;
|
||||||
|
double bottom = area.plotHeight() * 0.98;
|
||||||
|
double corrected = model.getX(tunerChannel.getFrequency() + correction, area.width());
|
||||||
|
double start = corrected - (box.width() / 2.0);
|
||||||
|
double stop = corrected + (box.width() / 2.0);
|
||||||
|
|
||||||
|
Color correctionColor = getSpectralDisplay().getTheme().getCorrection();
|
||||||
|
gc.setStroke(correctionColor);
|
||||||
|
gc.strokeLine(start, bottom, stop, bottom);
|
||||||
|
gc.strokeLine(start, top, start, bottom);
|
||||||
|
gc.strokeLine(stop, top, stop, bottom);
|
||||||
|
|
||||||
|
double ppm = correction / (tunerChannel.getFrequency() / 1E6);
|
||||||
|
String label = "PPM " + PPM_FORMAT.format(ppm);
|
||||||
|
mMeasure.setText(label);
|
||||||
|
|
||||||
|
if(mMeasure.getLayoutBounds().getWidth() <= box.width() && mTextHeight * 5 <= area.plotHeight())
|
||||||
|
{
|
||||||
|
gc.setFill(correctionColor);
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.setTextBaseline(VPos.BOTTOM);
|
||||||
|
gc.fillText(label, start + 1, bottom - 2);
|
||||||
|
gc.setTextBaseline(VPos.TOP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen position of the tuner channel, or null when it is outside the display.
|
||||||
|
*/
|
||||||
|
private static Box getBox(SpectralDisplayModel model, TunerChannel tunerChannel, RenderArea area)
|
||||||
|
{
|
||||||
|
long minimum = (long)Math.floor(model.getDisplayMinFrequency());
|
||||||
|
long maximum = (long)Math.ceil(model.getDisplayMaxFrequency());
|
||||||
|
|
||||||
|
if(tunerChannel == null || !tunerChannel.overlaps(minimum, maximum))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double center = model.getX(tunerChannel.getFrequency(), area.width());
|
||||||
|
double width = tunerChannel.getBandwidth() / model.getDisplayBandwidth() * area.width();
|
||||||
|
return new Box(center - (width / 2.0), width, center);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||||
|
{
|
||||||
|
List<MenuItem> items = new ArrayList<>();
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(model == null || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(Channel channel: getChannels(model))
|
||||||
|
{
|
||||||
|
if(!isShown(channel))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||||
|
{
|
||||||
|
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
|
||||||
|
frequency <= tunerChannel.getMaxFrequency())
|
||||||
|
{
|
||||||
|
//Traffic channels are started and stopped by their decoder, not from the playlist
|
||||||
|
if(!channel.isTrafficChannel() && mChannelProcessingManager != null)
|
||||||
|
{
|
||||||
|
items.add(createEnableItem(channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
|
||||||
|
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
|
||||||
|
items.add(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu item that starts the channel when it's disabled, or stops it when it's enabled, in the background
|
||||||
|
*/
|
||||||
|
private MenuItem createEnableItem(Channel channel)
|
||||||
|
{
|
||||||
|
boolean processing = channel.isProcessing();
|
||||||
|
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
|
||||||
|
item.setOnAction(e -> ThreadPool.CACHED.execute(() -> {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if(processing)
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.stop(channel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mChannelProcessingManager.start(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ChannelException ce)
|
||||||
|
{
|
||||||
|
showError((processing ? "Can't stop " : "Can't start ") + channel.getShortTitle(), ce.getMessage());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an error alert over the display. Can be called from any thread.
|
||||||
|
*/
|
||||||
|
private void showError(String header, String message)
|
||||||
|
{
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
|
||||||
|
alert.setTitle("Channel");
|
||||||
|
alert.setHeaderText(header);
|
||||||
|
|
||||||
|
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
|
||||||
|
{
|
||||||
|
alert.initOwner(getSpectralDisplay().getScene().getWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel box screen position.
|
||||||
|
*/
|
||||||
|
private record Box(double x, double width, double center)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel details for the popout card.
|
||||||
|
*/
|
||||||
|
private record PopoutEntry(String title, List<String> details, Color color)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum.layer;
|
||||||
|
|
||||||
|
import io.github.dsheirer.gui.spectrum.RenderArea;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.IntegerProperty;
|
||||||
|
import javafx.beans.property.LongProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleIntegerProperty;
|
||||||
|
import javafx.beans.property.SimpleLongProperty;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextAlignment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel tuning layer for a single channel's spectrum, centered on the channel frequency. Shades the channel
|
||||||
|
* passband and marks its edges with the theme's processing channel color, and draws the estimated carrier position
|
||||||
|
* from the decoder's frequency correction (PLL) as a line in the theme's correction color, labeled with the offset
|
||||||
|
* from the channel center. Both markers also show on the waterfall so carrier drift is easy to follow.
|
||||||
|
*/
|
||||||
|
public class ChannelTuningLayer extends SpectralDisplayLayer
|
||||||
|
{
|
||||||
|
private static final Font FONT = Font.font(11);
|
||||||
|
private static final DecimalFormat KHZ_FORMAT = new DecimalFormat("0.###");
|
||||||
|
private static final DecimalFormat OFFSET_FORMAT = new DecimalFormat("+0;-0");
|
||||||
|
|
||||||
|
private final IntegerProperty mChannelBandwidth = new SimpleIntegerProperty(0);
|
||||||
|
private final LongProperty mCarrierOffset = new SimpleLongProperty(0);
|
||||||
|
private final BooleanProperty mCarrierOffsetValid = new SimpleBooleanProperty(false);
|
||||||
|
private final Text mMeasure = new Text();
|
||||||
|
private final double mTextHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
*/
|
||||||
|
public ChannelTuningLayer()
|
||||||
|
{
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
mMeasure.setText("0123456789 kHz");
|
||||||
|
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||||
|
mChannelBandwidth.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
mCarrierOffset.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
mCarrierOffsetValid.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel bandwidth in Hertz, or zero for no passband markers. JavaFX thread only.
|
||||||
|
*/
|
||||||
|
public IntegerProperty channelBandwidthProperty()
|
||||||
|
{
|
||||||
|
return mChannelBandwidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated carrier offset from the channel center in Hertz. JavaFX thread only.
|
||||||
|
*/
|
||||||
|
public LongProperty carrierOffsetProperty()
|
||||||
|
{
|
||||||
|
return mCarrierOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the decoder has reported a carrier offset. The carrier marker only shows when true.
|
||||||
|
*/
|
||||||
|
public BooleanProperty carrierOffsetValidProperty()
|
||||||
|
{
|
||||||
|
return mCarrierOffsetValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getConfigurationTitle()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
double[] edges = getEdges(area.width());
|
||||||
|
|
||||||
|
if(edges != null)
|
||||||
|
{
|
||||||
|
gc.setFill(fade(getTheme().getChannelProcessing(), 0.6));
|
||||||
|
gc.fillRect(edges[0], 0, edges[1] - edges[0], area.plotHeight());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayTheme theme = getTheme();
|
||||||
|
double[] edges = getEdges(area.width());
|
||||||
|
|
||||||
|
gc.setFont(FONT);
|
||||||
|
gc.setTextBaseline(VPos.TOP);
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
|
||||||
|
if(edges != null)
|
||||||
|
{
|
||||||
|
Color edge = opaque(theme.getChannelProcessing());
|
||||||
|
gc.setStroke(edge);
|
||||||
|
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.plotHeight());
|
||||||
|
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.plotHeight());
|
||||||
|
|
||||||
|
String label = formatBandwidth(mChannelBandwidth.get());
|
||||||
|
mMeasure.setText(label);
|
||||||
|
|
||||||
|
if(mMeasure.getLayoutBounds().getWidth() + 8 < edges[1] - edges[0])
|
||||||
|
{
|
||||||
|
gc.setFill(edge);
|
||||||
|
gc.setTextAlign(TextAlignment.CENTER);
|
||||||
|
gc.fillText(label, (edges[0] + edges[1]) / 2.0, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Double carrier = getCarrierX(area.width());
|
||||||
|
|
||||||
|
if(carrier != null)
|
||||||
|
{
|
||||||
|
Color color = theme.getCorrection();
|
||||||
|
gc.setStroke(color);
|
||||||
|
gc.strokeLine(snap(carrier), 0, snap(carrier), area.plotHeight());
|
||||||
|
|
||||||
|
String label = "PLL " + OFFSET_FORMAT.format(mCarrierOffset.get()) + " Hz";
|
||||||
|
mMeasure.setText(label);
|
||||||
|
double width = mMeasure.getLayoutBounds().getWidth();
|
||||||
|
double x = carrier + 5;
|
||||||
|
|
||||||
|
if(x + width + 4 > area.width())
|
||||||
|
{
|
||||||
|
x = carrier - 5 - width;
|
||||||
|
}
|
||||||
|
|
||||||
|
double y = mTextHeight + 6;
|
||||||
|
gc.setFill(theme.getBackground(0.7));
|
||||||
|
gc.fillRoundRect(x - 3, y - 1, width + 6, mTextHeight + 2, 4, 4);
|
||||||
|
gc.setFill(color);
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.fillText(label, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayTheme theme = getTheme();
|
||||||
|
double[] edges = getEdges(area.width());
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
|
||||||
|
if(edges != null)
|
||||||
|
{
|
||||||
|
gc.setStroke(fade(opaque(theme.getChannelProcessing()), 0.7));
|
||||||
|
gc.setLineDashes(4.0, 4.0);
|
||||||
|
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.height());
|
||||||
|
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.height());
|
||||||
|
gc.setLineDashes((double[])null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Double carrier = getCarrierX(area.width());
|
||||||
|
|
||||||
|
if(carrier != null)
|
||||||
|
{
|
||||||
|
gc.setStroke(fade(theme.getCorrection(), 0.6));
|
||||||
|
gc.strokeLine(snap(carrier), 0, snap(carrier), area.height());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen x positions of the lower and upper channel edges, or null when there is no channel bandwidth.
|
||||||
|
*/
|
||||||
|
private double[] getEdges(double width)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
int bandwidth = mChannelBandwidth.get();
|
||||||
|
|
||||||
|
if(model == null || !model.hasSource() || bandwidth <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long center = model.getCenterFrequency();
|
||||||
|
return new double[]{model.getX(center - (bandwidth / 2.0), width), model.getX(center + (bandwidth / 2.0),
|
||||||
|
width)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen x position of the estimated carrier, or null when there is no carrier estimate.
|
||||||
|
*/
|
||||||
|
private Double getCarrierX(double width)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(model == null || !model.hasSource() || !mCarrierOffsetValid.get())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.getX(model.getCenterFrequency() + mCarrierOffset.get(), width);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpectralDisplayTheme getTheme()
|
||||||
|
{
|
||||||
|
return getSpectralDisplay().getTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a bandwidth in Hz or kHz.
|
||||||
|
*/
|
||||||
|
static String formatBandwidth(int hertz)
|
||||||
|
{
|
||||||
|
return hertz >= 1000 ? KHZ_FORMAT.format(hertz / 1000.0) + " kHz" : hertz + " Hz";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color opaque(Color color)
|
||||||
|
{
|
||||||
|
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color fade(Color color, double fraction)
|
||||||
|
{
|
||||||
|
return Color.color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * fraction);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double snap(double x)
|
||||||
|
{
|
||||||
|
return Math.floor(x) + 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* *****************************************************************************
|
||||||
|
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
* ****************************************************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.github.dsheirer.gui.spectrum.layer;
|
||||||
|
|
||||||
|
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
|
||||||
|
import io.github.dsheirer.gui.spectrum.RenderArea;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
|
||||||
|
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextAlignment;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decibel scale layer. Draws horizontal grid lines across the spectrum at even dBFS steps, with labels along the
|
||||||
|
* left edge. The step size (1, 2, 5, 10 or 20 dB) adapts to the spectrum height so the labels don't crowd.
|
||||||
|
*
|
||||||
|
* The spectrum plot runs from 0 dBFS at the top to minus the model's dynamic range at the bottom.
|
||||||
|
*/
|
||||||
|
public class DecibelLayer extends SpectralDisplayLayer
|
||||||
|
{
|
||||||
|
private static final Font FONT = Font.font(11);
|
||||||
|
private static final double[] STEPS = {1, 2, 5, 10, 20, 50};
|
||||||
|
private static final double MIN_LINE_SPACING = 28;
|
||||||
|
private static final double TOP_MARGIN = 22;
|
||||||
|
|
||||||
|
private final BooleanProperty mVisible = new SimpleBooleanProperty(true);
|
||||||
|
private final Text mMeasure = new Text();
|
||||||
|
private final double mTextHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an instance.
|
||||||
|
*/
|
||||||
|
public DecibelLayer()
|
||||||
|
{
|
||||||
|
mMeasure.setFont(FONT);
|
||||||
|
mMeasure.setText("-0123456789 dBFS");
|
||||||
|
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||||
|
mVisible.addListener((ob, ov, nv) -> requestRender());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the decibel scale.
|
||||||
|
*/
|
||||||
|
public BooleanProperty visibleProperty()
|
||||||
|
{
|
||||||
|
return mVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getConfigurationTitle()
|
||||||
|
{
|
||||||
|
//A single switch - list it in the Display section instead of a section of its own
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ConfigurationRow> getConfigurationRows()
|
||||||
|
{
|
||||||
|
return List.of(ConfigurationRow.toggle("dB Scale", mVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid line spacing in dB so the lines are at least MIN_LINE_SPACING pixels apart.
|
||||||
|
*/
|
||||||
|
static double getStep(double range, double plotHeight)
|
||||||
|
{
|
||||||
|
if(plotHeight <= 0 || range <= 0)
|
||||||
|
{
|
||||||
|
return STEPS[STEPS.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
double pixelsPerDb = plotHeight / range;
|
||||||
|
|
||||||
|
for(double step: STEPS)
|
||||||
|
{
|
||||||
|
if(step * pixelsPerDb >= MIN_LINE_SPACING)
|
||||||
|
{
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return STEPS[STEPS.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(!mVisible.get() || model == null || !model.hasSource())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double range = model.getDynamicRange();
|
||||||
|
double step = getStep(range, area.plotHeight());
|
||||||
|
gc.setStroke(getSpectralDisplay().getTheme().getLine(0.35));
|
||||||
|
gc.setLineWidth(1.0);
|
||||||
|
gc.setLineDashes(2.0, 4.0);
|
||||||
|
|
||||||
|
for(double decibels = -step; decibels > -range; decibels -= step)
|
||||||
|
{
|
||||||
|
double y = Math.floor(getY(decibels, range, area.plotHeight())) + 0.5;
|
||||||
|
gc.strokeLine(0, y, area.width(), y);
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.setLineDashes((double[])null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||||
|
{
|
||||||
|
SpectralDisplayModel model = getModel();
|
||||||
|
|
||||||
|
if(!mVisible.get() || model == null || !model.hasSource())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||||
|
double range = model.getDynamicRange();
|
||||||
|
double plotHeight = area.plotHeight();
|
||||||
|
double step = getStep(range, plotHeight);
|
||||||
|
boolean unitShown = false;
|
||||||
|
|
||||||
|
gc.setFont(FONT);
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.setTextBaseline(VPos.CENTER);
|
||||||
|
|
||||||
|
for(double decibels = -step; decibels > -range; decibels -= step)
|
||||||
|
{
|
||||||
|
double y = getY(decibels, range, plotHeight);
|
||||||
|
|
||||||
|
//Leave room for the zoom indicator at the top and the frequency labels at the bottom
|
||||||
|
if(y < TOP_MARGIN || y > plotHeight - (mTextHeight / 2.0))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String label = format(decibels) + (unitShown ? "" : " dBFS");
|
||||||
|
unitShown = true;
|
||||||
|
mMeasure.setText(label);
|
||||||
|
double width = mMeasure.getLayoutBounds().getWidth();
|
||||||
|
gc.setFill(theme.getBackground(0.6));
|
||||||
|
gc.fillRoundRect(2, y - (mTextHeight / 2.0), width + 6, mTextHeight, 4, 4);
|
||||||
|
gc.setFill(theme.getLine());
|
||||||
|
gc.fillText(label, 5, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double getY(double decibels, double range, double plotHeight)
|
||||||
|
{
|
||||||
|
return -decibels / range * plotHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String format(double decibels)
|
||||||
|
{
|
||||||
|
return decibels == Math.rint(decibels) ? String.valueOf((long)decibels) : String.valueOf(decibels);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user