mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
Compare commits
28 Commits
v1.0.0-bet
...
2660-now-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac8e80dd36 | ||
|
|
e9207e4067 | ||
|
|
a4a3a67901 | ||
|
|
c93d099742 | ||
|
|
be0966653b | ||
|
|
26a24fc6ad | ||
|
|
9deabd0dcc | ||
|
|
2db41393dc | ||
|
|
29a10edf03 | ||
|
|
5bce541631 | ||
|
|
9641d2ef59 | ||
|
|
73fc85aa76 | ||
|
|
aad971db9c | ||
|
|
73d3238644 | ||
|
|
4626d56730 | ||
|
|
1a90eaf6c6 | ||
|
|
fbc54a38dd | ||
|
|
07b61be257 | ||
|
|
74dc32538f | ||
|
|
7fcf035337 | ||
|
|
a53f6e9d99 | ||
|
|
e7f4f655e5 | ||
|
|
000ac51ff0 | ||
|
|
23cee388de | ||
|
|
5c73fb318f | ||
|
|
066554393f | ||
|
|
303c0a98cc | ||
|
|
02c62d012f |
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@@ -14,6 +14,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history and tags, so the build can describe the commit relative to the latest version tag
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 27
|
||||
uses: actions/setup-java@v6
|
||||
@@ -34,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Create Build Info file
|
||||
run: |
|
||||
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
|
||||
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\ndescribe: $(git describe --tags --match 'v[0-9]*' --dirty)\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
|
||||
|
||||
- name: Add build artifacts to nightly release
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
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
|
||||
# projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
|
||||
# release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
|
||||
# Publishes a release when a version tag is pushed: v0.6.2 for a final release,
|
||||
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the
|
||||
# version from the tag (see build.gradle).
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -22,15 +22,6 @@ jobs:
|
||||
# Full history and tags, to find the previous release tag for the release notes
|
||||
fetch-depth: 0
|
||||
|
||||
- id: version
|
||||
run: |
|
||||
expected="v$(sed -n 's/^projectVersion=//p' gradle.properties | tr -d '[:space:]')"
|
||||
if [ "$GITHUB_REF_NAME" != "$expected" ]; then
|
||||
echo "::error::Tag $GITHUB_REF_NAME does not match projectVersion in gradle.properties ($expected)"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Release notes list the PRs since the previous version tag (never 'nightly'). A final release
|
||||
# starts from the previous final release, so it covers every alpha and beta in between.
|
||||
- id: previous
|
||||
@@ -54,6 +45,17 @@ jobs:
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
# The version comes from 'git describe', which can pick a different tag when more than one
|
||||
# version tag points at the same commit. Fail rather than publish zips with the wrong version.
|
||||
- id: version
|
||||
run: |
|
||||
version="$(./gradlew -q printVersion --console=plain | tail -n 1)"
|
||||
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
||||
echo "::error::Build version $version does not match tag $GITHUB_REF_NAME"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and test
|
||||
run: ./gradlew clean build --console=plain
|
||||
|
||||
|
||||
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
|
||||
whenever a version tag is pushed to the repository.
|
||||
|
||||
1. Update `projectVersion` in `gradle.properties` to the new version, following the naming convention described in
|
||||
that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
|
||||
2. Commit and push the change to `master`.
|
||||
3. Create and push a tag that matches the version with a leading `v`:
|
||||
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag
|
||||
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`,
|
||||
`v0.6.2-beta-1` or `v0.6.2`):
|
||||
```
|
||||
git tag v0.6.2-beta-1
|
||||
git push origin v0.6.2-beta-1
|
||||
```
|
||||
|
||||
Builds made from an untagged commit get a version like `0.6.2-beta-1-14-g4626d567` (14 commits after the
|
||||
`v0.6.2-beta-1` tag, at commit `4626d567`), with `-dirty` appended when there are uncommitted changes.
|
||||
|
||||
The workflow then:
|
||||
* Verifies that the tag matches `projectVersion` in `gradle.properties` and fails if it does not.
|
||||
* Verifies that the version the build derives from git matches the tag and fails if it does not.
|
||||
* Builds and tests the code, then creates the release zips for Linux, Mac and Windows (x86-64 and aarch64).
|
||||
* Creates a GitHub release titled `Version <version>` with auto-generated release notes (a "What's Changed" list of
|
||||
the PRs merged since the previous version tag), and attaches the six zip
|
||||
|
||||
283
build.gradle
283
build.gradle
@@ -52,8 +52,44 @@ plugins {
|
||||
id 'org.beryx.runtime' version '2.0.1'
|
||||
}
|
||||
|
||||
//Version is defined in gradle.properties so that we can CLI override it for nightly builds.
|
||||
version = project.findProperty('projectVersion')
|
||||
/**
|
||||
* Version is derived from the most recent 'v<version>' git tag (e.g. v1.0.0-beta-1 -> 1.0.0-beta-1). Commits after
|
||||
* the tag produce a version like 1.0.0-beta-1-14-g4626d567 and uncommitted changes append '-dirty'. The moving
|
||||
* 'nightly' tag is ignored. Override with -PprojectVersion=<version> (the nightly build uses 'nightly' so that the
|
||||
* nightly release asset names stay the same). Falls back to 'unknown' when built outside a git checkout.
|
||||
*/
|
||||
String gitDescribe = describeGitVersion()
|
||||
version = project.findProperty('projectVersion') ?: (gitDescribe ? gitDescribe.substring(1) : 'unknown')
|
||||
|
||||
String describeGitVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
def describe = providers.exec {
|
||||
commandLine 'git', 'describe', '--tags', '--match', 'v[0-9]*', '--dirty'
|
||||
ignoreExitValue = true
|
||||
}
|
||||
|
||||
if(describe.result.get().exitValue == 0)
|
||||
{
|
||||
return describe.standardOutput.asText.get().trim()
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
logger.warn("Unable to determine version from git tags: " + e.message)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
tasks.register('printVersion') {
|
||||
description = 'Prints the project version'
|
||||
def projectVersion = version
|
||||
doLast {
|
||||
println projectVersion
|
||||
}
|
||||
}
|
||||
|
||||
//Java 27 is required for this version of the Project Panama preview/incubator feature
|
||||
java {
|
||||
@@ -95,35 +131,34 @@ test {
|
||||
dependencies {
|
||||
|
||||
// JUnit Tests
|
||||
testImplementation(platform("org.junit:junit-bom:6.0.1"))
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
testImplementation(platform("org.junit:junit-bom:6.1.3"))
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api'
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
|
||||
//Jitpack imports
|
||||
implementation 'com.github.dnault:libresample4j:master'
|
||||
implementation 'com.github.DSheirer.jmbe:api:v2.1.0'
|
||||
implementation 'com.github.DSheirer.jmbe:api:v2.1.1'
|
||||
implementation 'com.github.nwaldispuehl:java-lame:v3.98.4'
|
||||
|
||||
//mavenCentral/jcenter imports
|
||||
implementation 'ch.qos.logback:logback-classic:1.6.3'
|
||||
implementation 'ch.qos.logback:logback-core:1.5.38'
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.22.4'
|
||||
implementation 'com.fazecast:jSerialComm:2.11.4'
|
||||
implementation 'com.formdev:flatlaf:3.7.2'
|
||||
implementation 'com.formdev:flatlaf-intellij-themes:3.7.2'
|
||||
implementation 'ch.qos.logback:logback-classic:1.6.6'
|
||||
implementation 'ch.qos.logback:logback-core:1.6.6'
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.23.0'
|
||||
implementation 'com.formdev:flatlaf:3.8'
|
||||
implementation 'com.formdev:flatlaf-intellij-themes:3.8' //Not imported - Theme.java loads these themes by class name
|
||||
implementation 'com.github.jiconfont:jiconfont-font_awesome:4.7.0.1'
|
||||
implementation 'com.github.jiconfont:jiconfont-javafx:1.0.0'
|
||||
implementation 'com.github.jiconfont:jiconfont-swing:1.0.1'
|
||||
implementation 'com.github.wendykierp:JTransforms:3.2'
|
||||
implementation 'com.google.code.gson:gson:2.14.0'
|
||||
implementation 'com.google.guava:guava:33.6.0-jre'
|
||||
implementation('com.github.wendykierp:JTransforms:3.2') {
|
||||
exclude group: 'junit' //JLargeArrays 1.7 declares junit as a compile dependency
|
||||
}
|
||||
implementation 'com.google.code.gson:gson:2.15.0'
|
||||
implementation 'com.google.guava:guava:33.8.0-jre'
|
||||
implementation 'com.jidesoft:jide-oss:3.6.18'
|
||||
implementation 'com.miglayout:miglayout-swing:11.4.3'
|
||||
implementation 'com.miglayout:miglayout-swing:11.5.2'
|
||||
implementation 'com.mpatric:mp3agic:0.9.1'
|
||||
implementation 'commons-io:commons-io:2.22.0'
|
||||
implementation 'eu.hansolo:charts:1.0.5'
|
||||
implementation 'io.github.dsheirer:jairspy:1.0.12.1' //Airspy tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jairspyhf:1.8.1.1' //Airspy HF+ tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jbladerf:2.6.0.1' //bladeRF tuners - GitHub release, see settings.gradle
|
||||
@@ -131,20 +166,18 @@ dependencies {
|
||||
implementation 'io.github.dsheirer:jlibusb:1.0.30.1' //libusb and USB hotplug for the tuner libraries - GitHub release; not pulled in transitively
|
||||
implementation 'io.github.dsheirer:jhydrasdr:1.1.4.1' //HydraSDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jrtl:1.4.0.2' //RTL-SDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
|
||||
implementation 'net.coderazzi:tablefilter-swing:5.5.4'
|
||||
implementation 'org.apache.commons:commons-compress:1.28.0'
|
||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
|
||||
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
|
||||
implementation 'org.apache.commons:commons-lang3:3.21.0'
|
||||
implementation 'org.apache.commons:commons-math3:3.6.1'
|
||||
implementation 'org.apache.commons:commons-csv:1.14.1'
|
||||
implementation 'org.apache.mina:mina-core:2.2.9'
|
||||
implementation 'org.apache.mina:mina-http:2.2.9'
|
||||
implementation 'org.controlsfx:controlsfx:11.2.3'
|
||||
implementation 'org.apache.mina:mina-core:2.2.10'
|
||||
implementation 'org.apache.mina:mina-http:2.2.10'
|
||||
implementation 'org.controlsfx:controlsfx:11.2.6'
|
||||
implementation 'org.rauschig:jarchivelib:1.2.0'
|
||||
implementation 'org.slf4j:slf4j-api:2.0.17'
|
||||
implementation 'pl.edu.icm:JLargeArrays:1.6'
|
||||
implementation 'org.slf4j:slf4j-api:2.0.20'
|
||||
}
|
||||
|
||||
def os = DefaultNativePlatform.currentOperatingSystem
|
||||
@@ -205,6 +238,7 @@ jar {
|
||||
attributes (
|
||||
'Implementation-Title' : 'sdrtrunk project',
|
||||
'Implementation-Version' : archiveVersion,
|
||||
'Git-Describe' : gitDescribe ?: 'unknown',
|
||||
'Created-By' : "Gradle ${gradle.gradleVersion}",
|
||||
'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
|
||||
'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}",
|
||||
@@ -235,18 +269,6 @@ String targetOsxX86_64 = 'osx-x86_64-v' + version
|
||||
String targetWindowsX86_64 = 'windows-x86_64-v' + version
|
||||
String targetWindowsAarch64 = 'windows-aarch64-v' + version
|
||||
|
||||
/**
|
||||
* Optional pre-downloaded Java Development Kit (JDK) for each target OS and CPU. These locations are hard-coded to the
|
||||
* author's development environment, but can be changed to your environment if you want to use this build pattern.
|
||||
*/
|
||||
String jdk_base = '/home/denny/java_jdks/'
|
||||
String jdk_linux_aarch64 = jdk_base + 'linux-arm64/jdk-25.0.1-full'
|
||||
String jdk_linux_x86_64 = jdk_base + 'linux-x64/jdk-25.0.1-full'
|
||||
String jdk_osx_x86_64 = jdk_base + 'osx-x64/jdk-25.0.1-full.jdk'
|
||||
String jdk_osx_aarch64 = jdk_base + 'osx-arm64/jdk-25.0.1-full.jdk'
|
||||
String jdk_windows_x86_64 = jdk_base + 'windows-x64/jdk-25.0.1-full'
|
||||
String jdk_windows_aarch64 = jdk_base + 'windows-arm64/jdk-25.0.1-full'
|
||||
|
||||
/**
|
||||
* Download URLs to download the JDK as part of the gradle build packaging process
|
||||
*/
|
||||
@@ -275,19 +297,138 @@ def configure(RuntimeZipTask rt, List<String> jvmArgs) {
|
||||
//jdk.incubator.vector - needed for Project Panama foreign function and vector apis
|
||||
//jdk.accessibility is used with assistive technologies like screen readers
|
||||
//java.management for JVM resource monitoring
|
||||
rt.extension.addModules('jdk.charsets', 'jdk.crypto.ec', 'jdk.incubator.vector', 'jdk.accessibility', 'java.management')
|
||||
//java.base, java.logging, java.net.http, java.prefs, javafx.controls, javafx.swing - required by sdrtrunk (jdeps)
|
||||
rt.extension.addModules('jdk.charsets', 'jdk.crypto.ec', 'jdk.incubator.vector', 'jdk.accessibility', 'java.management',
|
||||
'java.base', 'java.logging', 'java.net.http', 'java.prefs', 'javafx.controls', 'javafx.swing')
|
||||
|
||||
//Use auto-detected modules and 'add' any specified modules.
|
||||
rt.extension.additive.set(true)
|
||||
//Use only the modules listed above. The runtime plugin's module auto-detection uses a bundled ASM library that
|
||||
//can't read Java 27 class files and logs a 'Failed to scan' error for each sdrtrunk class. The jdeps module check
|
||||
//on the runtime task verifies that this list is complete.
|
||||
rt.extension.additive.set(false)
|
||||
|
||||
//Recommended options for reducing/shrinking runtime target image size
|
||||
rt.extension.addOptions('--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages')
|
||||
rt.extension.addOptions('--strip-debug', '--compress', 'zip-6', '--no-header-files', '--no-man-pages')
|
||||
|
||||
//Runtime images have auto appended version in image zip name. Override to include version in single target image.
|
||||
// rt.extension.imageDir=layout.buildDirectory.dir('image')
|
||||
rt.extension.imageZip.set(file(layout.buildDirectory.dir('image').get().file('sdr-trunk.zip')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the jar file without the native library folders for other operating systems and architectures. Fails the
|
||||
* build if a jar that contains native libraries doesn't contain the folder for the target.
|
||||
* @param jar file to rewrite
|
||||
* @param keep native folder for the target
|
||||
* @return number of bytes removed from the jar
|
||||
*/
|
||||
long stripJar(File jar, String keep) {
|
||||
def tunerPattern = ~/^[^\/]+\/native\/([^\/]+)\/.*/
|
||||
def removed = [] as Set
|
||||
def hadTuner = false, keptTuner = false
|
||||
File temp = new File(jar.parentFile, jar.name + '.tmp')
|
||||
|
||||
new java.util.zip.ZipFile(jar).withCloseable { zip ->
|
||||
def entries = zip.entries().toList()
|
||||
|
||||
entries.each { e ->
|
||||
def tuner = tunerPattern.matcher(e.name)
|
||||
|
||||
if(tuner.matches()) {
|
||||
hadTuner = true
|
||||
if(tuner.group(1) == keep) { keptTuner = true } else { removed << e.name }
|
||||
}
|
||||
}
|
||||
|
||||
if(hadTuner && !keptTuner) {
|
||||
throw new GradleException("Native strip: ${jar.name} has no native/${keep} folder")
|
||||
}
|
||||
|
||||
if(!removed.isEmpty()) {
|
||||
new java.util.zip.ZipOutputStream(new FileOutputStream(temp)).withCloseable { out ->
|
||||
entries.findAll { !removed.contains(it.name) }.each { e ->
|
||||
def copy = new java.util.zip.ZipEntry(e)
|
||||
if(copy.method == java.util.zip.ZipEntry.DEFLATED) {
|
||||
copy.compressedSize = -1
|
||||
}
|
||||
out.putNextEntry(copy)
|
||||
zip.getInputStream(e).withCloseable { out << it }
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(removed.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
long before = jar.length()
|
||||
java.nio.file.Files.move(temp.toPath(), jar.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING)
|
||||
return before - jar.length()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the native libraries for other operating systems and architectures from the jars in each runtime target
|
||||
* image, before the runtimeZip tasks package the images. The tuner libraries (jRTL, jAirspy, etc.) package their
|
||||
* native libraries under '<library>/native/<os>-<arch>/', where the folder name matches the runtime target name
|
||||
* '<os>-<arch>-v<version>' without the version suffix, except that the libraries use 'macos' where the target uses 'osx'.
|
||||
*/
|
||||
tasks.named('runtime') {rt ->
|
||||
rt.doLast {
|
||||
rt.targetPlatforms.get().keySet().each { target ->
|
||||
def nativeFolder = (target - "-v${version}").replaceFirst(/^osx-/, 'macos-')
|
||||
def lib = new File(rt.imageDir.asFile, "${project.name}-${target}/lib")
|
||||
long total = 0
|
||||
lib.listFiles({ File f -> f.name.endsWith('.jar') } as FileFilter).each { jar ->
|
||||
total += stripJar(jar, nativeFolder)
|
||||
}
|
||||
logger.lifecycle("Native strip: removed ${String.format('%.1f', total / 1048576)} MB of other " +
|
||||
"platform native libraries from [${target}]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that each runtime target image contains every module that the sdrtrunk jar requires, as reported by jdeps.
|
||||
* This guards against the runtime plugin's module auto-detection silently missing modules (e.g. when it can't parse
|
||||
* class files compiled for a newer Java version).
|
||||
*/
|
||||
def jdepsHome = javaToolchains.launcherFor(java.toolchain).map { it.metadata.installationPath.asFile }
|
||||
def mainJar = tasks.named('jar').flatMap { it.archiveFile }
|
||||
|
||||
tasks.named('runtime') {rt ->
|
||||
rt.doLast {
|
||||
def jdeps = new File(jdepsHome.get(), 'bin/' + (os.isWindows() ? 'jdeps.exe' : 'jdeps')).absolutePath
|
||||
def process = new ProcessBuilder(jdeps, '--multi-release', java.toolchain.languageVersion.get().toString(),
|
||||
'--ignore-missing-deps', '--print-module-deps', '-q', mainJar.get().asFile.absolutePath)
|
||||
.redirectErrorStream(true).start()
|
||||
def output = process.inputStream.text.trim()
|
||||
|
||||
if(process.waitFor() != 0) {
|
||||
throw new GradleException("Module check: jdeps failed: ${output}")
|
||||
}
|
||||
|
||||
def required = output.split(',')*.trim().findAll { it } as Set
|
||||
|
||||
rt.targetPlatforms.get().keySet().each { target ->
|
||||
def release = new File(rt.imageDir.asFile, "${project.name}-${target}/release")
|
||||
def match = release.text =~ /(?m)^MODULES="([^"]*)"/
|
||||
|
||||
if(!match.find()) {
|
||||
throw new GradleException("Module check: no MODULES entry in ${release}")
|
||||
}
|
||||
|
||||
def missing = required - (match.group(1).split(' ') as Set)
|
||||
|
||||
if(missing) {
|
||||
throw new GradleException("Module check: runtime image [${target}] is missing required modules " +
|
||||
"${missing.sort()} - add them to addModules() in build.gradle")
|
||||
}
|
||||
}
|
||||
logger.lifecycle("Module check: runtime images contain all modules required by sdrtrunk: ${required.sort()}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a release package for the current operating system.
|
||||
*
|
||||
@@ -344,22 +485,12 @@ tasks.register('runtimeZipCurrent', RuntimeZipTask) {rt ->
|
||||
*/
|
||||
tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
|
||||
//Windows aarch64
|
||||
if(file(jdk_windows_aarch64).exists()) {
|
||||
rt.extension.targetPlatform(targetWindowsAarch64, jdk_windows_aarch64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetWindowsAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_windows_aarch64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetWindowsAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_windows_aarch64)
|
||||
}
|
||||
//Windows x86-64
|
||||
if(file(jdk_windows_x86_64).exists()) {
|
||||
rt.extension.targetPlatform(targetWindowsX86_64, jdk_windows_x86_64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetWindowsX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_windows_x86_64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetWindowsX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_windows_x86_64)
|
||||
}
|
||||
|
||||
configure(rt, jvmArgsWindows)
|
||||
@@ -376,40 +507,20 @@ tasks.register('runtimeZipWindows', RuntimeZipTask) {rt ->
|
||||
*/
|
||||
tasks.register('runtimeZipOthers', RuntimeZipTask) {rt ->
|
||||
//Linux aarch64
|
||||
if(file(jdk_linux_aarch64).exists()) {
|
||||
rt.extension.targetPlatform(targetLinuxAarch64, jdk_linux_aarch64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetLinuxAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_linux_aarch64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetLinuxAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_linux_aarch64)
|
||||
}
|
||||
//Linux x86-64
|
||||
if(file(jdk_linux_x86_64).exists()) {
|
||||
rt.extension.targetPlatform(targetLinuxX86_64, jdk_linux_x86_64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetLinuxX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_linux_x86_64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetLinuxX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_linux_x86_64)
|
||||
}
|
||||
//OSX aarch64
|
||||
if(file(jdk_osx_aarch64).exists()) {
|
||||
rt.extension.targetPlatform(targetOsxAarch64, jdk_osx_aarch64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetOsxAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_osx_aarch64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetOsxAarch64) {
|
||||
jdkHome = jdkDownload(jdk_download_osx_aarch64)
|
||||
}
|
||||
//OSX x86-64
|
||||
if(file(jdk_osx_x86_64).exists()) {
|
||||
rt.extension.targetPlatform(targetOsxX86_64, jdk_osx_x86_64, [])
|
||||
}
|
||||
else {
|
||||
rt.extension.targetPlatform(targetOsxX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_osx_x86_64)
|
||||
}
|
||||
rt.extension.targetPlatform(targetOsxX86_64) {
|
||||
jdkHome = jdkDownload(jdk_download_osx_x86_64)
|
||||
}
|
||||
|
||||
configure(rt, jvmArgsLinux)
|
||||
|
||||
@@ -17,15 +17,16 @@
|
||||
# *****************************************************************************
|
||||
#
|
||||
|
||||
# Version naming conventions:
|
||||
# Alpha: maj.min.patch-alpha-build
|
||||
# Beta: maj.min.patch-beta-build
|
||||
# Release: maj.min.patch
|
||||
# The project version is no longer defined here. It is derived from the most recent 'v<version>' git tag (see
|
||||
# build.gradle) and can be overridden with -PprojectVersion=<version>.
|
||||
#
|
||||
# Example: 0.6.0-beta-5
|
||||
# Version tag naming conventions:
|
||||
# Alpha: vmaj.min.patch-alpha-build
|
||||
# Beta: vmaj.min.patch-beta-build
|
||||
# Release: vmaj.min.patch
|
||||
#
|
||||
# Note: this is so that the file names for the resultant release build products match the GitLab release asset
|
||||
# Example: v0.6.0-beta-5
|
||||
#
|
||||
# Note: this is so that the file names for the resultant release build products match the GitHub release asset
|
||||
# tag name, specifically the dashes inserted before and after the alpha/beta labels.
|
||||
# See: https://github.com/DSheirer/sdrtrunk/issues/1651
|
||||
|
||||
projectVersion=1.0.0-beta-1
|
||||
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
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
52
gradlew.bat
vendored
52
gradlew.bat
vendored
@@ -26,6 +26,33 @@
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||
@rem This can be removed once we can be reasonably confident that few people
|
||||
@rem will be migrating directly to this new wrapper.
|
||||
goto afterSafetyNet
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
goto exitWithErrorLevel
|
||||
:afterSafetyNet
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
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)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url = 'https://jitpack.io' }
|
||||
|
||||
//jAirspy is published as a GitHub release, with the native libraries for all platforms in the jar
|
||||
ivy {
|
||||
@@ -82,5 +82,14 @@ dependencyResolutionManagement {
|
||||
metadataSources { artifact() }
|
||||
content { includeModule 'io.github.dsheirer', 'jbladerf' }
|
||||
}
|
||||
|
||||
//radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api)
|
||||
//are not pulled in transitively and are declared directly in build.gradle
|
||||
ivy {
|
||||
url = 'https://github.com/DSheirer/radio-reference-api/releases/download'
|
||||
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||
metadataSources { artifact() }
|
||||
content { includeModule 'io.github.dsheirer', 'radio-reference-api' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
|
||||
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
|
||||
@@ -49,6 +50,7 @@ import java.net.SocketAddress;
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
|
||||
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
|
||||
@JsonSubTypes.Type(value = ThinlineRadioConfiguration.class, name="ThinlineRadioConfiguration"),
|
||||
@JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
|
||||
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
|
||||
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),
|
||||
|
||||
@@ -24,6 +24,7 @@ import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfigura
|
||||
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyFeedConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerFeedConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
||||
@@ -68,6 +69,7 @@ public class BroadcastFactory
|
||||
return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration,
|
||||
inputAudioFormat, mp3Setting, aliasModel);
|
||||
case RDIOSCANNER_CALL:
|
||||
case THINLINE_RADIO:
|
||||
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
|
||||
inputAudioFormat, mp3Setting, aliasModel);
|
||||
case OPENMHZ:
|
||||
@@ -113,6 +115,8 @@ public class BroadcastFactory
|
||||
return new BroadcastifyCallConfiguration(format);
|
||||
case RDIOSCANNER_CALL:
|
||||
return new RdioScannerConfiguration(format);
|
||||
case THINLINE_RADIO:
|
||||
return new ThinlineRadioConfiguration(format);
|
||||
case BROADCASTIFY:
|
||||
return new BroadcastifyFeedConfiguration(format);
|
||||
case OPENMHZ:
|
||||
|
||||
@@ -32,6 +32,7 @@ public enum BroadcastServerType
|
||||
|
||||
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
|
||||
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
|
||||
THINLINE_RADIO("Thinline Radio", "images/rdioscanner.png"),
|
||||
OPENMHZ("OpenMHz", "images/openmhz.png"),
|
||||
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
|
||||
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),
|
||||
|
||||
@@ -113,7 +113,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.error("Error connecting to Rdio Scanner server on startup [" + response + "]");
|
||||
mLog.error("Error connecting to " + getBroadcastConfiguration().getServiceName() +
|
||||
" server on startup [" + response + "]");
|
||||
setBroadcastState(BroadcastState.ERROR);
|
||||
}
|
||||
|
||||
@@ -249,7 +250,8 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
mLog.error("Rdio Scanner API - audio recording file not found - ignoring upload");
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() +
|
||||
" API - audio recording file not found - ignoring upload");
|
||||
}
|
||||
|
||||
if(audioBytes != null)
|
||||
@@ -286,14 +288,14 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
//We get socket reset exceptions occasionally when the remote server doesn't
|
||||
//fully read our request and immediately responds.
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
else
|
||||
{
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
@@ -321,7 +323,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
else
|
||||
{
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
@@ -334,7 +336,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
else
|
||||
{
|
||||
//Register an error for the file not found exception
|
||||
mLog.error("Rdio Scanner API - upload file not found [" +
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API - upload file not found [" +
|
||||
audioRecording.getPath().toString() + "]");
|
||||
incrementErrorAudioCount();
|
||||
broadcast(new BroadcastEvent(RdioScannerBroadcaster.this,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
package io.github.dsheirer.audio.broadcast.rdioscanner;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
|
||||
@@ -125,6 +126,15 @@ public class RdioScannerConfiguration extends BroadcastConfiguration
|
||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product name written to connection and upload logs.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public String getServiceName()
|
||||
{
|
||||
return "Rdio Scanner";
|
||||
}
|
||||
|
||||
@Override
|
||||
public BroadcastConfiguration copyOf()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,11 @@ public abstract class AmbeAudioModule extends JmbeAudioModule
|
||||
{
|
||||
if(getAudioCodec() != null)
|
||||
{
|
||||
mLog.info("AMBE CODEC successfully loaded - P25-2/DMR/NXDN audio will be available");
|
||||
mLog.info("AMBE CODEC successfully loaded - P25-2/DMR/NXDN audio can be processed");
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.warn("AMBE CODEC not loaded - P25-2/DMR/NXDN audio will NOT be available");
|
||||
mLog.warn("AMBE CODEC not loaded - P25-2/DMR/NXDN audio will NOT be processed");
|
||||
}
|
||||
|
||||
sLibraryStatusLogged = true;
|
||||
|
||||
@@ -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,10 +57,11 @@ public class ByteNativeBuffer extends AbstractNativeBuffer
|
||||
public ByteNativeBuffer(byte[] samples, long timestamp, float averageDc, float samplesPerMillisecond)
|
||||
{
|
||||
super(timestamp, samplesPerMillisecond);
|
||||
//Ensure we're an even multiple of the fragment size. Typically, this will be 64k or 128k
|
||||
if(samples.length % FRAGMENT_SIZE != 0)
|
||||
//Ensure we're an even multiple of the fragment size in bytes (2 bytes per I/Q sample). Typically, this
|
||||
//will be 64k or 128k
|
||||
if(samples.length % (FRAGMENT_SIZE * 2) != 0)
|
||||
{
|
||||
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + FRAGMENT_SIZE);
|
||||
throw new IllegalArgumentException("Samples byte[] length [" + samples.length + "] must be an even multiple of " + (FRAGMENT_SIZE * 2));
|
||||
}
|
||||
|
||||
mSamples = samples;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/*******************************************************************************
|
||||
* sdrtrunk
|
||||
* Copyright (C) 2014-2017 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
******************************************************************************/
|
||||
package io.github.dsheirer.channel.details;
|
||||
|
||||
import io.github.dsheirer.channel.state.DecoderState;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.text.DefaultCaret;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
|
||||
public class ChannelDetailPanel extends JPanel implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final String EMPTY_DETAILS = "Please select a channel to view details";
|
||||
|
||||
private JLabel mSystemLabel;
|
||||
private JLabel mSiteLabel;
|
||||
private JLabel mNameLabel;
|
||||
private JTextArea mDetailTextPane;
|
||||
|
||||
private ChannelProcessingManager mChannelProcessingManager;
|
||||
private ProcessingChain mProcessingChain;
|
||||
|
||||
public ChannelDetailPanel(ChannelProcessingManager channelProcessingManager)
|
||||
{
|
||||
mChannelProcessingManager = channelProcessingManager;
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[]0[grow,fill]"));
|
||||
|
||||
JPanel buttonPanel = new JPanel();
|
||||
buttonPanel.setLayout(new MigLayout("insets 1 1 1 1", "[][grow,fill][][grow,fill][][grow,fill][]", ""));
|
||||
|
||||
buttonPanel.add(new JLabel("System:"));
|
||||
mSystemLabel = new JLabel(" ");
|
||||
buttonPanel.add(mSystemLabel);
|
||||
|
||||
buttonPanel.add(new JLabel("Site:"));
|
||||
mSiteLabel = new JLabel(" ");
|
||||
buttonPanel.add(mSiteLabel);
|
||||
|
||||
buttonPanel.add(new JLabel("Channel Name:"));
|
||||
mNameLabel = new JLabel(" ");
|
||||
buttonPanel.add(mNameLabel);
|
||||
|
||||
JButton refreshButton = new JButton("Refresh");
|
||||
refreshButton.addActionListener(new ActionListener()
|
||||
{
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e)
|
||||
{
|
||||
receive(mProcessingChain);
|
||||
}
|
||||
});
|
||||
buttonPanel.add(refreshButton);
|
||||
|
||||
add(buttonPanel, "wrap");
|
||||
|
||||
mDetailTextPane = new JTextArea(EMPTY_DETAILS);
|
||||
DefaultCaret caret = (DefaultCaret)mDetailTextPane.getCaret();
|
||||
caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
|
||||
add(new JScrollPane(mDetailTextPane));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
mProcessingChain = processingChain;
|
||||
|
||||
Channel channel = mChannelProcessingManager.getChannel(processingChain);
|
||||
|
||||
final String system = channel != null ? channel.getSystem() : null;
|
||||
final String site = channel != null ? channel.getSite() : null;
|
||||
final String name = channel != null ?
|
||||
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null;
|
||||
|
||||
final String details;
|
||||
|
||||
if(processingChain != null)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for(DecoderState decoderState : processingChain.getDecoderStates())
|
||||
{
|
||||
sb.append(decoderState.getActivitySummary());
|
||||
}
|
||||
|
||||
details = sb.toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
details = EMPTY_DETAILS;
|
||||
}
|
||||
|
||||
EventQueue.invokeLater(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
mSystemLabel.setText(system);
|
||||
mSiteLabel.setText(site);
|
||||
mNameLabel.setText(name);
|
||||
mDetailTextPane.setText(details);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2024 Dennis Sheirer
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -18,67 +18,73 @@
|
||||
*/
|
||||
package io.github.dsheirer.channel.metadata;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.awt.EventQueue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import javax.swing.table.AbstractTableModel;
|
||||
|
||||
public class ChannelMetadataModel extends AbstractTableModel implements IChannelMetadataUpdateListener
|
||||
/**
|
||||
* Model of the channel metadata for the currently processing channels, with a map from each channel metadata to the
|
||||
* channel that it belongs to. The model is thread-safe and isn't tied to a user interface toolkit: listeners are
|
||||
* notified on the thread that changes the model and pass the changes to their user interface thread.
|
||||
*/
|
||||
public class ChannelMetadataModel implements IChannelMetadataUpdateListener
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataModel.class);
|
||||
private final List<ChannelMetadata> mChannelMetadata = new ArrayList<>();
|
||||
private final Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap<>();
|
||||
private final List<Listener> mListeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static final int COLUMN_DECODER_STATE = 0;
|
||||
public static final int COLUMN_DECODER_TYPE = 1;
|
||||
public static final int COLUMN_USER_FROM = 2;
|
||||
public static final int COLUMN_USER_FROM_ALIAS = 3;
|
||||
public static final int COLUMN_USER_TO = 4;
|
||||
public static final int COLUMN_USER_TO_ALIAS = 5;
|
||||
public static final int COLUMN_DECODER_LOGICAL_CHANNEL_NAME = 6;
|
||||
public static final int COLUMN_CONFIGURATION_FREQUENCY = 7;
|
||||
public static final int COLUMN_CONFIGURATION_CHANNEL = 8;
|
||||
|
||||
private static final String[] COLUMNS = {"Status", "Decoder", "From", "Alias", "To", "Alias", "Channel", "Frequency", "Channel Name"};
|
||||
|
||||
private List<ChannelMetadata> mChannelMetadata = new ArrayList();
|
||||
private Map<ChannelMetadata,Channel> mMetadataChannelMap = new HashMap();
|
||||
private Listener<ChannelAndMetadata> mChannelAddListener;
|
||||
|
||||
public ChannelMetadataModel()
|
||||
/**
|
||||
* Listener for changes to the channel metadata model. Notifications arrive on the thread that changed the
|
||||
* model, sometimes while the model is locked, so implementations must not block and must not call back into
|
||||
* the model from the notification thread.
|
||||
*/
|
||||
public interface Listener
|
||||
{
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
/**
|
||||
* Channel metadata(s) for a channel were added to the model.
|
||||
*/
|
||||
void added(ChannelAndMetadata channelAndMetadata);
|
||||
|
||||
/**
|
||||
* Channel metadata was removed from the model.
|
||||
*/
|
||||
void removed(ChannelMetadata channelMetadata);
|
||||
|
||||
/**
|
||||
* A field of a channel metadata in the model was updated.
|
||||
*/
|
||||
void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives preference update notifications via the event bus
|
||||
* @param preferenceType that was updated
|
||||
* Constructs an instance
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
public ChannelMetadataModel()
|
||||
{
|
||||
if(preferenceType == PreferenceType.TALKGROUP_FORMAT)
|
||||
{
|
||||
EventQueue.invokeLater(() -> {
|
||||
for(int row = 0; row < mChannelMetadata.size(); row++)
|
||||
{
|
||||
fireTableCellUpdated(row, COLUMN_USER_FROM);
|
||||
fireTableCellUpdated(row, COLUMN_USER_TO);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the listener to receive model change notifications.
|
||||
* @param listener to register
|
||||
* @return the channel metadata in the model when the listener is registered, so that the listener starts from
|
||||
* the current state and receives the changes that follow.
|
||||
*/
|
||||
public synchronized List<ChannelMetadata> addListener(Listener listener)
|
||||
{
|
||||
mListeners.add(listener);
|
||||
return new ArrayList<>(mChannelMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the listener from receiving model change notifications.
|
||||
*/
|
||||
public void removeListener(Listener listener)
|
||||
{
|
||||
mListeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,10 +93,16 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
||||
*/
|
||||
public String getDiagnosticInformation()
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Map<ChannelMetadata,Channel> mapCopy = new HashMap<>(mMetadataChannelMap);
|
||||
List<ChannelMetadata> metadataCopy = new ArrayList<>(mChannelMetadata);
|
||||
Map<ChannelMetadata,Channel> mapCopy;
|
||||
List<ChannelMetadata> metadataCopy;
|
||||
|
||||
synchronized(this)
|
||||
{
|
||||
mapCopy = new HashMap<>(mMetadataChannelMap);
|
||||
metadataCopy = new ArrayList<>(mChannelMetadata);
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Channel Metadata Model Diagnostics\n");
|
||||
|
||||
for(ChannelMetadata channelMetadata: metadataCopy)
|
||||
@@ -122,51 +134,34 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the model for disposal.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
MyEventBus.getGlobalEventBus().unregister(this);
|
||||
mListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup model row for the specified channel metadata
|
||||
* Adds the channel metadata(s) for a channel to the model.
|
||||
*/
|
||||
public int getRow(ChannelMetadata channelMetadata)
|
||||
public synchronized void add(ChannelAndMetadata channelAndMetadata)
|
||||
{
|
||||
if(mChannelMetadata.contains(channelMetadata))
|
||||
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
|
||||
{
|
||||
return mChannelMetadata.indexOf(channelMetadata);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the listener to be notified when channel metadata(s) are added to the model.
|
||||
* @param listener to receive channel associated with the channel metadata(s) that were added
|
||||
*/
|
||||
public void setChannelAddListener(Listener<ChannelAndMetadata> listener)
|
||||
{
|
||||
mChannelAddListener = listener;
|
||||
}
|
||||
|
||||
public void add(ChannelAndMetadata channelAndMetadata)
|
||||
{
|
||||
//Execute on the swing thread to avoid threading issues
|
||||
EventQueue.invokeLater(() -> {
|
||||
for(ChannelMetadata channelMetadata: channelAndMetadata.getChannelMetadata())
|
||||
if(!mChannelMetadata.contains(channelMetadata))
|
||||
{
|
||||
mChannelMetadata.add(channelMetadata);
|
||||
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
|
||||
int index = mChannelMetadata.indexOf(channelMetadata);
|
||||
fireTableRowsInserted(index, index);
|
||||
channelMetadata.setUpdateEventListener(ChannelMetadataModel.this);
|
||||
}
|
||||
|
||||
if(mChannelAddListener != null)
|
||||
{
|
||||
mChannelAddListener.receive(channelAndMetadata);
|
||||
}
|
||||
});
|
||||
mMetadataChannelMap.put(channelMetadata, channelAndMetadata.getChannel());
|
||||
channelMetadata.setUpdateEventListener(this);
|
||||
}
|
||||
|
||||
for(Listener listener: mListeners)
|
||||
{
|
||||
listener.added(channelAndMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +169,7 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
||||
* @param channelMetadatas
|
||||
* @param channel
|
||||
*/
|
||||
public void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
|
||||
public synchronized void updateChannelMetadataToChannelMap(Collection<ChannelMetadata> channelMetadatas, Channel channel)
|
||||
{
|
||||
for(ChannelMetadata channelMetadata: channelMetadatas)
|
||||
{
|
||||
@@ -182,178 +177,46 @@ public class ChannelMetadataModel extends AbstractTableModel implements IChannel
|
||||
}
|
||||
}
|
||||
|
||||
public void remove(ChannelMetadata channelMetadata)
|
||||
/**
|
||||
* Removes the channel metadata from the model.
|
||||
*/
|
||||
public synchronized void remove(ChannelMetadata channelMetadata)
|
||||
{
|
||||
//Execute on the swing thread to avoid threading issues
|
||||
EventQueue.invokeLater(() -> {
|
||||
channelMetadata.removeUpdateEventListener();
|
||||
int index = mChannelMetadata.indexOf(channelMetadata);
|
||||
mChannelMetadata.remove(channelMetadata);
|
||||
mMetadataChannelMap.remove(channelMetadata);
|
||||
channelMetadata.removeUpdateEventListener();
|
||||
mMetadataChannelMap.remove(channelMetadata);
|
||||
|
||||
if(index >= 0)
|
||||
if(mChannelMetadata.remove(channelMetadata))
|
||||
{
|
||||
for(Listener listener: mListeners)
|
||||
{
|
||||
fireTableRowsDeleted(index, index);
|
||||
listener.removed(channelMetadata);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channel metadata at the specified model row index
|
||||
* Current channel metadata in the model.
|
||||
* @return copy of the channel metadata list.
|
||||
*/
|
||||
public ChannelMetadata getChannelMetadata(int row)
|
||||
public synchronized List<ChannelMetadata> getChannelMetadata()
|
||||
{
|
||||
if(row < mChannelMetadata.size())
|
||||
{
|
||||
return mChannelMetadata.get(row);
|
||||
}
|
||||
|
||||
return null;
|
||||
return new ArrayList<>(mChannelMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the channel that matches the metadata or null
|
||||
*/
|
||||
public Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
|
||||
public synchronized Channel getChannelFromMetadata(ChannelMetadata channelMetadata)
|
||||
{
|
||||
return mMetadataChannelMap.get(channelMetadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowCount()
|
||||
{
|
||||
return mChannelMetadata.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnCount()
|
||||
{
|
||||
return COLUMNS.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getColumnName(int column)
|
||||
{
|
||||
return COLUMNS[column];
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<?> getColumnClass(int columnIndex)
|
||||
{
|
||||
switch(columnIndex)
|
||||
{
|
||||
case COLUMN_USER_FROM:
|
||||
case COLUMN_USER_TO:
|
||||
return ChannelMetadata.class;
|
||||
case COLUMN_USER_FROM_ALIAS:
|
||||
case COLUMN_USER_TO_ALIAS:
|
||||
return Alias.class;
|
||||
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
|
||||
return String.class;
|
||||
default:
|
||||
return Identifier.class;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getValueAt(int rowIndex, int columnIndex)
|
||||
{
|
||||
if(rowIndex <= mChannelMetadata.size())
|
||||
{
|
||||
ChannelMetadata channelMetadata = mChannelMetadata.get(rowIndex);
|
||||
|
||||
switch(columnIndex)
|
||||
{
|
||||
case COLUMN_DECODER_STATE:
|
||||
return channelMetadata.getChannelStateIdentifier();
|
||||
case COLUMN_DECODER_TYPE:
|
||||
return channelMetadata.getDecoderTypeConfigurationIdentifier();
|
||||
case COLUMN_DECODER_LOGICAL_CHANNEL_NAME:
|
||||
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
|
||||
|
||||
if(channelMetadata.hasTimeslot())
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return "TS:" + channelMetadata.getTimeslot();
|
||||
}
|
||||
else
|
||||
{
|
||||
String value = id.getValue().replace(" TS1", "")
|
||||
.replace(" TS2", "");
|
||||
return value + " TS:" + channelMetadata.getTimeslot();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
return id.getValue();
|
||||
}
|
||||
}
|
||||
case COLUMN_CONFIGURATION_FREQUENCY:
|
||||
return channelMetadata.getFrequencyConfigurationIdentifier();
|
||||
case COLUMN_CONFIGURATION_CHANNEL:
|
||||
return channelMetadata.getChannelNameConfigurationIdentifier();
|
||||
case COLUMN_USER_TO:
|
||||
return channelMetadata;
|
||||
case COLUMN_USER_FROM:
|
||||
return channelMetadata;
|
||||
case COLUMN_USER_FROM_ALIAS:
|
||||
return channelMetadata.getFromIdentifierAliases();
|
||||
case COLUMN_USER_TO_ALIAS:
|
||||
return channelMetadata.getToIdentifierAliases();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
|
||||
{
|
||||
final int rowIndex = mChannelMetadata.indexOf(channelMetadata);
|
||||
|
||||
if(rowIndex >= 0)
|
||||
for(Listener listener: mListeners)
|
||||
{
|
||||
//Execute on the swing thread to avoid threading issues
|
||||
EventQueue.invokeLater(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
switch(channelMetadataField)
|
||||
{
|
||||
case CONFIGURATION_CHANNEL:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_CHANNEL);
|
||||
break;
|
||||
case CONFIGURATION_FREQUENCY:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_CONFIGURATION_FREQUENCY);
|
||||
break;
|
||||
case DECODER_CHANNEL_NAME:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_DECODER_LOGICAL_CHANNEL_NAME);
|
||||
break;
|
||||
case DECODER_TYPE:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_DECODER_TYPE);
|
||||
break;
|
||||
case DECODER_STATE:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_DECODER_STATE);
|
||||
break;
|
||||
case USER_FROM:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM);
|
||||
fireTableCellUpdated(rowIndex, COLUMN_USER_FROM_ALIAS);
|
||||
break;
|
||||
case USER_TO:
|
||||
fireTableCellUpdated(rowIndex, COLUMN_USER_TO);
|
||||
fireTableCellUpdated(rowIndex, COLUMN_USER_TO_ALIAS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
listener.updated(channelMetadata, channelMetadataField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2024 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.channel.metadata;
|
||||
|
||||
import com.google.common.base.Joiner;
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import io.github.dsheirer.channel.state.State;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.controller.channel.ChannelModel;
|
||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
|
||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.configuration.FrequencyConfigurationIdentifier;
|
||||
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
|
||||
import io.github.dsheirer.preference.swing.JTableColumnWidthMonitor;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.event.ListSelectionEvent;
|
||||
import javax.swing.event.ListSelectionListener;
|
||||
import javax.swing.table.DefaultTableCellRenderer;
|
||||
|
||||
public class ChannelMetadataPanel extends JPanel implements ListSelectionListener
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(ChannelMetadataPanel.class);
|
||||
|
||||
private static final String TABLE_PREFERENCE_KEY = "channel.metadata.panel";
|
||||
private ChannelModel mChannelModel;
|
||||
private ChannelProcessingManager mChannelProcessingManager;
|
||||
private IconModel mIconModel;
|
||||
private UserPreferences mUserPreferences;
|
||||
private JTable mTable;
|
||||
private Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
|
||||
private Map<State,Color> mBackgroundColors = new EnumMap<>(State.class);
|
||||
private Map<State,Color> mForegroundColors = new EnumMap<>(State.class);
|
||||
private JTableColumnWidthMonitor mTableColumnMonitor;
|
||||
private Channel mUserSelectedChannel;
|
||||
|
||||
/**
|
||||
* Table view for currently decoding channel metadata
|
||||
*/
|
||||
public ChannelMetadataPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences)
|
||||
{
|
||||
mChannelModel = playlistManager.getChannelModel();
|
||||
mChannelProcessingManager = playlistManager.getChannelProcessingManager();
|
||||
mIconModel = iconModel;
|
||||
mUserPreferences = userPreferences;
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the panel
|
||||
*/
|
||||
private void init()
|
||||
{
|
||||
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
|
||||
|
||||
mTable = new JTable(mChannelProcessingManager.getChannelMetadataModel());
|
||||
mChannelProcessingManager.getChannelMetadataModel().setChannelAddListener(new ChannelAddListener());
|
||||
|
||||
DefaultTableCellRenderer renderer = (DefaultTableCellRenderer)mTable.getDefaultRenderer(String.class);
|
||||
renderer.setHorizontalAlignment(SwingConstants.CENTER);
|
||||
|
||||
mTable.getSelectionModel().addListSelectionListener(this);
|
||||
mTable.addMouseListener(new MouseSupport());
|
||||
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_DECODER_STATE)
|
||||
.setCellRenderer(new ColoredStateCellRenderer());
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM)
|
||||
.setCellRenderer(new FromCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO)
|
||||
.setCellRenderer(new ToCellRenderer(mUserPreferences.getTalkgroupFormatPreference()));
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_FROM_ALIAS)
|
||||
.setCellRenderer(new AliasCellRenderer());
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_USER_TO_ALIAS)
|
||||
.setCellRenderer(new AliasCellRenderer());
|
||||
mTable.getColumnModel().getColumn(ChannelMetadataModel.COLUMN_CONFIGURATION_FREQUENCY)
|
||||
.setCellRenderer(new FrequencyCellRenderer());
|
||||
|
||||
//Add a table column width monitor to store/restore column widths
|
||||
mTableColumnMonitor = new JTableColumnWidthMonitor(mUserPreferences, mTable, TABLE_PREFERENCE_KEY);
|
||||
|
||||
JScrollPane scrollPane = new JScrollPane(mTable, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
|
||||
JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
|
||||
|
||||
add(scrollPane);
|
||||
|
||||
setColors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the background and foreground color palette for the various channel states.
|
||||
*/
|
||||
private void setColors()
|
||||
{
|
||||
mBackgroundColors.put(State.ACTIVE, Color.CYAN);
|
||||
mForegroundColors.put(State.ACTIVE, Color.BLUE);
|
||||
mBackgroundColors.put(State.CALL, Color.BLUE);
|
||||
mForegroundColors.put(State.CALL, Color.YELLOW);
|
||||
mBackgroundColors.put(State.CONTROL, Color.ORANGE);
|
||||
mForegroundColors.put(State.CONTROL, Color.BLUE);
|
||||
mBackgroundColors.put(State.DATA, Color.GREEN);
|
||||
mForegroundColors.put(State.DATA, Color.BLUE);
|
||||
mBackgroundColors.put(State.ENCRYPTED, Color.MAGENTA);
|
||||
mForegroundColors.put(State.ENCRYPTED, Color.WHITE);
|
||||
mBackgroundColors.put(State.FADE, Color.LIGHT_GRAY);
|
||||
mForegroundColors.put(State.FADE, Color.DARK_GRAY);
|
||||
mBackgroundColors.put(State.IDLE, Color.WHITE);
|
||||
mForegroundColors.put(State.IDLE, Color.DARK_GRAY);
|
||||
mBackgroundColors.put(State.RESET, Color.PINK);
|
||||
mForegroundColors.put(State.RESET, Color.YELLOW);
|
||||
mBackgroundColors.put(State.TEARDOWN, Color.DARK_GRAY);
|
||||
mForegroundColors.put(State.TEARDOWN, Color.WHITE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void valueChanged(ListSelectionEvent e)
|
||||
{
|
||||
if(!mTable.getSelectionModel().getValueIsAdjusting())
|
||||
{
|
||||
ProcessingChain processingChain = null;
|
||||
|
||||
int selectedViewRow = mTable.getSelectedRow();
|
||||
|
||||
if(selectedViewRow >= 0)
|
||||
{
|
||||
int selectedModelRow = mTable.convertRowIndexToModel(selectedViewRow);
|
||||
|
||||
ChannelMetadata selectedMetadata = mChannelProcessingManager.getChannelMetadataModel()
|
||||
.getChannelMetadata(selectedModelRow);
|
||||
|
||||
if(selectedMetadata != null)
|
||||
{
|
||||
mUserSelectedChannel = mChannelProcessingManager.getChannelMetadataModel()
|
||||
.getChannelFromMetadata(selectedMetadata);
|
||||
|
||||
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
|
||||
}
|
||||
}
|
||||
|
||||
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the listener to receive the processing chain associated with the metadata selected in the
|
||||
* metadata table.
|
||||
*/
|
||||
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
|
||||
{
|
||||
mSelectedProcessingChainBroadcaster.addListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell renderer for frequency values
|
||||
*/
|
||||
public class FrequencyCellRenderer extends DefaultTableCellRenderer
|
||||
{
|
||||
private final DecimalFormat FREQUENCY_FORMATTER = new DecimalFormat( "#.00000" );
|
||||
|
||||
public FrequencyCellRenderer()
|
||||
{
|
||||
setHorizontalAlignment(JLabel.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
|
||||
{
|
||||
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
|
||||
|
||||
if(value instanceof FrequencyConfigurationIdentifier)
|
||||
{
|
||||
long frequency = ((FrequencyConfigurationIdentifier)value).getValue();
|
||||
label.setText(FREQUENCY_FORMATTER.format(frequency / 1e6d));
|
||||
}
|
||||
else
|
||||
{
|
||||
label.setText(null);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias cell renderer
|
||||
*/
|
||||
public class AliasCellRenderer extends DefaultTableCellRenderer
|
||||
{
|
||||
public AliasCellRenderer()
|
||||
{
|
||||
setHorizontalAlignment(JLabel.LEFT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
|
||||
{
|
||||
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
|
||||
|
||||
if(value instanceof List<?>)
|
||||
{
|
||||
List<Alias> aliases = (List<Alias>)value;
|
||||
|
||||
if(!aliases.isEmpty())
|
||||
{
|
||||
label.setText(Joiner.on(", ").skipNulls().join(aliases));
|
||||
label.setIcon(mIconModel.getIcon(aliases.get(0).getIconName(), IconModel.DEFAULT_ICON_SIZE));
|
||||
label.setForeground(ThemeManager.getInstance().getReadableColor(aliases.get(0).getDisplayColor(),
|
||||
label.getBackground()));
|
||||
}
|
||||
else
|
||||
{
|
||||
label.setText(null);
|
||||
label.setIcon(null);
|
||||
label.setForeground(table.getForeground());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
label.setText(null);
|
||||
label.setIcon(null);
|
||||
label.setForeground(table.getForeground());
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract cell renderer for identifiers
|
||||
*/
|
||||
public abstract class IdentifierCellRenderer extends DefaultTableCellRenderer
|
||||
{
|
||||
private final static String EMPTY_VALUE = "-----";
|
||||
private TalkgroupFormatPreference mTalkgroupFormatPreference;
|
||||
|
||||
public IdentifierCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
|
||||
{
|
||||
mTalkgroupFormatPreference = talkgroupFormatPreference;
|
||||
setHorizontalAlignment(JLabel.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column)
|
||||
{
|
||||
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
|
||||
|
||||
if(value instanceof ChannelMetadata)
|
||||
{
|
||||
ChannelMetadata channelMetadata = (ChannelMetadata)value;
|
||||
Identifier identifier = getIdentifier(channelMetadata);
|
||||
String text = mTalkgroupFormatPreference.format(identifier);
|
||||
if(text == null || text.isEmpty())
|
||||
{
|
||||
text = EMPTY_VALUE;
|
||||
}
|
||||
else if(hasAdditionalIdentifier(channelMetadata))
|
||||
{
|
||||
text = text + " " + getAdditionalIdentifier(channelMetadata);
|
||||
}
|
||||
|
||||
label.setText(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
label.setText(EMPTY_VALUE);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
public abstract Identifier getIdentifier(ChannelMetadata channelMetadata);
|
||||
public abstract boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata);
|
||||
public abstract Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell renderer for the FROM identifier
|
||||
*/
|
||||
public class FromCellRenderer extends IdentifierCellRenderer
|
||||
{
|
||||
public FromCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
|
||||
{
|
||||
super(talkgroupFormatPreference);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Identifier getIdentifier(ChannelMetadata channelMetadata)
|
||||
{
|
||||
return channelMetadata.getFromIdentifier();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata)
|
||||
{
|
||||
return channelMetadata.getTalkerAliasIdentifier();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata)
|
||||
{
|
||||
return channelMetadata.hasTalkerAliasIdentifier();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell renderer for the TO identifier
|
||||
*/
|
||||
public class ToCellRenderer extends IdentifierCellRenderer
|
||||
{
|
||||
public ToCellRenderer(TalkgroupFormatPreference talkgroupFormatPreference)
|
||||
{
|
||||
super(talkgroupFormatPreference);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Identifier getIdentifier(ChannelMetadata channelMetadata)
|
||||
{
|
||||
return channelMetadata.getToIdentifier();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Identifier getAdditionalIdentifier(ChannelMetadata channelMetadata) {return null;}
|
||||
@Override
|
||||
public boolean hasAdditionalIdentifier(ChannelMetadata channelMetadata) {return false;}
|
||||
}
|
||||
|
||||
public class ColoredStateCellRenderer extends DefaultTableCellRenderer
|
||||
{
|
||||
public ColoredStateCellRenderer()
|
||||
{
|
||||
setHorizontalAlignment(SwingConstants.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus,
|
||||
int row, int column)
|
||||
{
|
||||
JLabel label = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
|
||||
|
||||
Color background = table.getBackground();
|
||||
Color foreground = table.getForeground();
|
||||
|
||||
if(value instanceof ChannelStateIdentifier)
|
||||
{
|
||||
State state = ((ChannelStateIdentifier)value).getValue();
|
||||
label.setText(state.getDisplayValue());
|
||||
|
||||
if(mBackgroundColors.containsKey(state))
|
||||
{
|
||||
background = mBackgroundColors.get(state);
|
||||
}
|
||||
|
||||
if(mForegroundColors.containsKey(state))
|
||||
{
|
||||
foreground = mForegroundColors.get(state);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
setText("----");
|
||||
}
|
||||
|
||||
setBackground(background);
|
||||
setForeground(foreground);
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseSupport extends MouseAdapter
|
||||
{
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e)
|
||||
{
|
||||
if(e.getButton() == MouseEvent.BUTTON3) //Right click for context
|
||||
{
|
||||
JPopupMenu popupMenu = new JPopupMenu();
|
||||
|
||||
boolean populated = false;
|
||||
|
||||
int viewRowIndex = mTable.rowAtPoint(e.getPoint());
|
||||
|
||||
if(viewRowIndex >= 0)
|
||||
{
|
||||
int modelRowIndex = mTable.convertRowIndexToModel(viewRowIndex);
|
||||
|
||||
if(modelRowIndex >= 0)
|
||||
{
|
||||
ChannelMetadata metadata = mChannelProcessingManager.getChannelMetadataModel().getChannelMetadata(modelRowIndex);
|
||||
|
||||
if(metadata != null)
|
||||
{
|
||||
Channel channel = mChannelProcessingManager.getChannelMetadataModel()
|
||||
.getChannelFromMetadata(metadata);
|
||||
|
||||
if(channel != null)
|
||||
{
|
||||
JMenuItem viewChannel = new JMenuItem("View/Edit: " + channel.getShortTitle());
|
||||
viewChannel.addActionListener(e2 -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
|
||||
popupMenu.add(viewChannel);
|
||||
populated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!populated)
|
||||
{
|
||||
popupMenu.add(new JMenuItem("No Actions Available"));
|
||||
}
|
||||
|
||||
popupMenu.show(mTable, e.getX(), e.getY());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener to be notified when a channel and associated channel metadata(s) are added to the underlying
|
||||
* channel metadata model.
|
||||
*
|
||||
* When a channel is added, it is compared the the last user selected channel and if they are the same, it
|
||||
* invokes a selection event on the channel metadata row so that the channel metadata is re-selected. This is
|
||||
* primarily a hack to counter-act the DMR Capacity+ REST channel rotation where a channel is converted to a
|
||||
* traffic channel and the previous channel is restarted. The UI effect is that the user selected channel row
|
||||
* in the Now Playing window continually loses selection over the channel and causes the user to perpetually
|
||||
* chase the channel row.
|
||||
*/
|
||||
public class ChannelAddListener implements Listener<ChannelAndMetadata>
|
||||
{
|
||||
@Override
|
||||
public void receive(ChannelAndMetadata channelAndMetadata)
|
||||
{
|
||||
if(mUserSelectedChannel != null &&
|
||||
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
|
||||
{
|
||||
List<ChannelMetadata> metadata = channelAndMetadata.getChannelMetadata();
|
||||
|
||||
if(metadata.size() > 0)
|
||||
{
|
||||
int modelRow = mChannelProcessingManager.getChannelMetadataModel().getRow(metadata.get(0));
|
||||
|
||||
if(modelRow >= 0)
|
||||
{
|
||||
int tableRow = mTable.convertRowIndexToView(modelRow);
|
||||
mTable.getSelectionModel().setSelectionInterval(tableRow, tableRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2025 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.channel.metadata;
|
||||
|
||||
import com.jidesoft.swing.JideSplitPane;
|
||||
import com.jidesoft.swing.JideTabbedPane;
|
||||
import io.github.dsheirer.channel.details.ChannelDetailPanel;
|
||||
import io.github.dsheirer.gui.channel.ChannelSpectrumPanel;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.module.decode.event.DecodeEventPanel;
|
||||
import io.github.dsheirer.module.decode.event.MessageActivityPanel;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
|
||||
import javax.swing.JPanel;
|
||||
|
||||
/**
|
||||
* Swing panel for Now Playing channels table and channel details tab set.
|
||||
*/
|
||||
public class NowPlayingPanel extends JPanel
|
||||
{
|
||||
private final ChannelMetadataPanel mChannelMetadataPanel;
|
||||
private final ChannelDetailPanel mChannelDetailPanel;
|
||||
private final DecodeEventPanel mDecodeEventPanel;
|
||||
private final MessageActivityPanel mMessageActivityPanel;
|
||||
private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel;
|
||||
private JideTabbedPane mTabbedPane;
|
||||
private JideSplitPane mSplitPane;
|
||||
private boolean mDetailTabsVisible;
|
||||
|
||||
/**
|
||||
* GUI panel that combines the currently decoding channels metadata table and viewers for channel details,
|
||||
* messages, events, and spectral view.
|
||||
*/
|
||||
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
|
||||
SettingsManager settingsManager, boolean detailTabsVisible)
|
||||
{
|
||||
mChannelDetailPanel = new ChannelDetailPanel(playlistManager.getChannelProcessingManager());
|
||||
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
|
||||
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
|
||||
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
|
||||
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
|
||||
mDetailTabsVisible = detailTabsVisible;
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the visibility of the channel details tabs panel.
|
||||
* @param visible true to show or false to hide.
|
||||
*/
|
||||
public void setDetailTabsVisible(boolean visible)
|
||||
{
|
||||
//Only adjust if there is a change in state
|
||||
if(visible ^ mDetailTabsVisible)
|
||||
{
|
||||
mDetailTabsVisible = visible;
|
||||
|
||||
if(mDetailTabsVisible)
|
||||
{
|
||||
getSplitPane().add(getTabbedPane());
|
||||
}
|
||||
else
|
||||
{
|
||||
getSplitPane().remove(getTabbedPane());
|
||||
}
|
||||
|
||||
revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private JideTabbedPane getTabbedPane()
|
||||
{
|
||||
if(mTabbedPane == null)
|
||||
{
|
||||
mTabbedPane = new JideTabbedPane();
|
||||
mTabbedPane.addTab("Details", mChannelDetailPanel);
|
||||
mTabbedPane.addTab("Events", mDecodeEventPanel);
|
||||
mTabbedPane.addTab("Messages", mMessageActivityPanel);
|
||||
mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel);
|
||||
mTabbedPane.setFont(this.getFont());
|
||||
//Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing
|
||||
mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane()
|
||||
.indexOfComponent(mChannelSpectrumSquelchPanel)));
|
||||
}
|
||||
|
||||
return mTabbedPane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split pane for channels table and channel details tabs.
|
||||
*/
|
||||
private JideSplitPane getSplitPane()
|
||||
{
|
||||
if(mSplitPane == null)
|
||||
{
|
||||
mSplitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT);
|
||||
mSplitPane.setShowGripper(true);
|
||||
}
|
||||
|
||||
return mSplitPane;
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
setLayout( new MigLayout( "insets 0 0 0 0", "[grow,fill]", "[grow,fill]") );
|
||||
getSplitPane().add(mChannelMetadataPanel);
|
||||
|
||||
if(mDetailTabsVisible)
|
||||
{
|
||||
getSplitPane().add(getTabbedPane());
|
||||
}
|
||||
|
||||
add(getSplitPane());
|
||||
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel);
|
||||
mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel);
|
||||
mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel);
|
||||
mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,11 @@ package io.github.dsheirer.controller;
|
||||
import com.jidesoft.swing.JideTabbedPane;
|
||||
import io.github.dsheirer.audio.playback.AudioPanel;
|
||||
import io.github.dsheirer.audio.playback.AudioPlaybackManager;
|
||||
import io.github.dsheirer.channel.metadata.NowPlayingPanel;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.gui.nowplaying.NowPlayingPanel;
|
||||
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||
import io.github.dsheirer.map.MapPanel;
|
||||
import io.github.dsheirer.map.MapService;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
@@ -62,8 +63,10 @@ public class ControllerPanel extends JPanel
|
||||
{
|
||||
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
||||
playlistManager.getAliasModel());
|
||||
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager);
|
||||
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, detailTabsVisible);
|
||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
|
||||
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||
new RadioReferencePlaylistActions(playlistManager));
|
||||
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
||||
|
||||
init();
|
||||
|
||||
@@ -153,6 +153,14 @@ public class ChannelProcessingManager implements Listener<ChannelEvent>
|
||||
return !mProcessingChainsMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that currently have a processing chain, including temporary traffic channels.
|
||||
*/
|
||||
public List<Channel> getProcessingChannels()
|
||||
{
|
||||
return new ArrayList<>(mProcessingChainsMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current processing chain associated with the channel, or
|
||||
* null if a processing chain is not currently setup for the channel
|
||||
|
||||
@@ -257,6 +257,20 @@ public abstract class C4FMSymbolProcessor
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum bit errors allowed in a detected sync pattern while acquiring (ie not synchronized), measured from the
|
||||
* symbol decisions at the optimized symbol timing. Sync detections with more bit errors are rejected. Bit errors
|
||||
* include symbol magnitude (ie +3 vs +1) errors, so this rejects noise where the symbol signs correlate with the
|
||||
* sync pattern but the symbol magnitudes are inconsistent. Once synchronized, the limit isn't applied since the
|
||||
* sync detection timing is known and rejecting a valid sync detection costs the burst. Default implementation
|
||||
* imposes no limit.
|
||||
* @return maximum sync pattern bit error count while acquiring.
|
||||
*/
|
||||
public int getSyncAcquisitionMaxBitErrors()
|
||||
{
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of consecutive sync periods (capped at 3) where a sync was detected within the max message length.
|
||||
*/
|
||||
@@ -945,12 +959,14 @@ public abstract class C4FMSymbolProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this correction has a valid NID or the optimization (or detection) score is high quality.
|
||||
* Indicates if the optimization score is high quality and, while acquiring, the sync pattern bit errors don't
|
||||
* exceed the max.
|
||||
* @return true if valid.
|
||||
*/
|
||||
public boolean isValid()
|
||||
{
|
||||
return mOptimizationScore > getSyncThresholdEqualized();
|
||||
return mOptimizationScore > getSyncThresholdEqualized() &&
|
||||
(mSynchronized || mCorrectedBitCount <= getSyncAcquisitionMaxBitErrors());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,7 +1313,7 @@ public abstract class C4FMSymbolProcessor
|
||||
|
||||
for(int x = 0; x < patternSymbols.length; x++)
|
||||
{
|
||||
if(bufferPointer < maxPointer)
|
||||
if(bufferPointer >= 0 && bufferPointer < maxPointer)
|
||||
{
|
||||
softSymbol = interpolate(mBuffer[bufferPointer], mBuffer[bufferPointer + 1], fractional) * gain + balance;
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package io.github.dsheirer.dsp.gain;
|
||||
|
||||
public class NonClippingGain
|
||||
{
|
||||
private float mGain;
|
||||
private float mMaxValue;
|
||||
|
||||
/**
|
||||
* Applies a fixed gain value to the float samples.
|
||||
*
|
||||
* @param gain value
|
||||
* @param maxValue max absolute value to prevent clipping ( 0.0 to 1.0 )
|
||||
*/
|
||||
public NonClippingGain( float gain, float maxValue )
|
||||
{
|
||||
mGain = gain;
|
||||
mMaxValue = maxValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies gain to the sample
|
||||
*/
|
||||
public float apply( float sample )
|
||||
{
|
||||
float adjusted = sample * mGain;
|
||||
|
||||
if( adjusted > mMaxValue )
|
||||
{
|
||||
adjusted = mMaxValue;
|
||||
}
|
||||
if( adjusted < -mMaxValue )
|
||||
{
|
||||
adjusted = -mMaxValue;
|
||||
}
|
||||
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies gain to the sample array
|
||||
*/
|
||||
public float[] apply( float[] samples )
|
||||
{
|
||||
for( int x = 0; x < samples.length; x++ )
|
||||
{
|
||||
samples[ x ] = apply( samples[ x ]);
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,29 @@ public class Golay24
|
||||
*/
|
||||
public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex)
|
||||
{
|
||||
//The correction below indexes the word from bit 0 and takes the parity over the whole message, so it is only
|
||||
//correct for a message that is exactly the 24-bit word. Decode any other word in a 24-bit copy.
|
||||
if(startIndex != 0 || message.size() != 24)
|
||||
{
|
||||
CorrectedBinaryMessage word = new CorrectedBinaryMessage(24);
|
||||
int original = message.getInt(startIndex, startIndex + 23);
|
||||
word.load(0, 24, original);
|
||||
int result = checkAndCorrectWord(word);
|
||||
int corrected = word.getInt(0, 23);
|
||||
message.load(startIndex, 24, corrected);
|
||||
message.incrementCorrectedBitCount(Integer.bitCount(original ^ corrected));
|
||||
return result;
|
||||
}
|
||||
|
||||
return checkAndCorrectWord(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error detection and correction of a message that is exactly one 24-bit word.
|
||||
*/
|
||||
private static int checkAndCorrectWord(CorrectedBinaryMessage message)
|
||||
{
|
||||
int startIndex = 0;
|
||||
boolean parityError = message.cardinality() % 2 != 0;
|
||||
|
||||
int syndrome = getSyndrome(message, startIndex);
|
||||
|
||||
@@ -37,7 +37,8 @@ public class Hamming13
|
||||
*
|
||||
* @param message containing a Hamming protected word
|
||||
* @param offset to the start of the protected word
|
||||
* @return message index for an error bit or -1 if no errors are detected.
|
||||
* @return message index for an error bit, -1 if no errors are detected, or 1000 if more than one bit error is
|
||||
* detected.
|
||||
*/
|
||||
public static int getErrorIndex(BinaryMessage message, int offset)
|
||||
{
|
||||
@@ -45,7 +46,8 @@ public class Hamming13
|
||||
|
||||
if(syndrome > 0)
|
||||
{
|
||||
return offset + ERROR_INDEX[syndrome];
|
||||
int errorIndex = ERROR_INDEX[syndrome];
|
||||
return errorIndex >= 0 ? offset + errorIndex : IHamming.MULTIPLE_ERRORS;
|
||||
}
|
||||
|
||||
return IHamming.NO_ERRORS;
|
||||
|
||||
@@ -47,13 +47,6 @@ public class Hamming16 implements IHamming
|
||||
return NO_ERRORS;
|
||||
}
|
||||
|
||||
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
|
||||
//flag it as invalid for multiple errors.
|
||||
if(syndrome == 1 && message.getSubMessage(offset, offset + 16).cardinality() % 2 == 1) //check final parity bit
|
||||
{
|
||||
return MULTIPLE_ERRORS;
|
||||
}
|
||||
|
||||
for(int index = 0; index < CHECKSUMS.length; index++)
|
||||
{
|
||||
if(syndrome == CHECKSUMS[index])
|
||||
|
||||
@@ -46,13 +46,6 @@ public class Hamming17 implements IHamming
|
||||
return NO_ERRORS;
|
||||
}
|
||||
|
||||
//If the syndrome indicates the error is in the final parity bit position, and we already have odd parity, then
|
||||
//flag it as invalid for multiple errors.
|
||||
if(syndrome == 1 && message.getSubMessage(offset, offset + 17).cardinality() % 2 == 1) //check final parity bit
|
||||
{
|
||||
return MULTIPLE_ERRORS;
|
||||
}
|
||||
|
||||
for(int index = 0; index < CHECKSUMS.length; index++)
|
||||
{
|
||||
if(syndrome == CHECKSUMS[index])
|
||||
|
||||
421
src/main/java/io/github/dsheirer/edac/ProductCodeDecoder.java
Normal file
421
src/main/java/io/github/dsheirer/edac/ProductCodeDecoder.java
Normal file
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.edac;
|
||||
|
||||
import io.github.dsheirer.bits.BinaryMessage;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
/**
|
||||
* Hard decision minimum distance decoder for binary product codes, where each row is a codeword of a short row code
|
||||
* and each column is a codeword of a systematic column code with the column parity bits in the final rows.
|
||||
*
|
||||
* Finds the minimum weight error pattern that produces the received row and column syndromes, using an iterative
|
||||
* deepening search over the error patterns (coset) of each information row syndrome. The column parity row errors
|
||||
* are fully determined by the column syndromes and the information row error choices, and the column parity rows then
|
||||
* also match their row syndromes, so only the information rows are searched.
|
||||
*
|
||||
* Corrects every error pattern up to half the product code minimum distance, and most patterns up to the maximum
|
||||
* error count. When more than one error pattern has the minimum weight, the received word is equidistant from more
|
||||
* than one codeword and is treated as uncorrectable.
|
||||
*
|
||||
* Row words are integers where bit i (LSB = 0) holds the bit in column i. Instances are thread safe.
|
||||
*/
|
||||
public class ProductCodeDecoder
|
||||
{
|
||||
public static final int UNCORRECTABLE = -1;
|
||||
//Bounds the decode time for pathological inputs (~20 ms). Error injection testing with random error patterns of
|
||||
//each weight: the largest search was 630k nodes (3 ms) for BPTC(196,96) with a maximum error count of 12.
|
||||
private static final long MAXIMUM_SEARCH_NODES = 4_000_000;
|
||||
private final int mColumnCount;
|
||||
private final int mRowCount;
|
||||
private final int mInformationRowCount;
|
||||
private final int[] mRowChecks;
|
||||
private final int[] mColumnChecks;
|
||||
private final int mColumnCheckBits;
|
||||
private final long mColumnGroupMask;
|
||||
private final int mMaximumErrors;
|
||||
private final int[][] mCosets;
|
||||
private final int[] mMinimumWeights;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param rowChecks parity check matrix column (syndrome of a single bit error) for each row code bit position, where
|
||||
* the array length is the column count (maximum 20).
|
||||
* @param columnChecks parity check matrix column for each column code bit position, where the array length is the
|
||||
* row count. The column code must be systematic, with distinct single bit parity checks for the final (parity)
|
||||
* rows. For a single (even) parity check column code, each value is 1.
|
||||
* @param maximumErrors maximum number of bit errors to correct.
|
||||
*/
|
||||
public ProductCodeDecoder(int[] rowChecks, int[] columnChecks, int maximumErrors)
|
||||
{
|
||||
mColumnCount = rowChecks.length;
|
||||
mRowCount = columnChecks.length;
|
||||
mRowChecks = rowChecks.clone();
|
||||
mColumnChecks = columnChecks.clone();
|
||||
mMaximumErrors = maximumErrors;
|
||||
mColumnCheckBits = bitLength(mColumnChecks);
|
||||
mInformationRowCount = mRowCount - mColumnCheckBits;
|
||||
|
||||
if(mColumnCount < 1 || mColumnCount > 20)
|
||||
{
|
||||
throw new IllegalArgumentException("Column count must be in range 1 - 20");
|
||||
}
|
||||
|
||||
if(mColumnCount * mColumnCheckBits > Long.SIZE || mInformationRowCount < 1)
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported column code dimensions");
|
||||
}
|
||||
|
||||
//Verify the final rows are the systematic parity rows
|
||||
int parityChecks = 0;
|
||||
|
||||
for(int row = mInformationRowCount; row < mRowCount; row++)
|
||||
{
|
||||
int check = mColumnChecks[row];
|
||||
|
||||
if(Integer.bitCount(check) != 1 || (parityChecks & check) != 0)
|
||||
{
|
||||
throw new IllegalArgumentException("Column code parity rows must have distinct single bit checks");
|
||||
}
|
||||
|
||||
parityChecks |= check;
|
||||
}
|
||||
|
||||
long groupMask = 0;
|
||||
|
||||
for(int column = 0; column < mColumnCount; column++)
|
||||
{
|
||||
groupMask |= 1L << (column * mColumnCheckBits);
|
||||
}
|
||||
|
||||
mColumnGroupMask = groupMask;
|
||||
|
||||
//Coset lists: the error patterns for each row syndrome, up to the maximum error count, ordered by weight
|
||||
int syndromeCount = 1 << bitLength(mRowChecks);
|
||||
int maximumWeight = Math.min(maximumErrors, mColumnCount);
|
||||
int wordCount = 1 << mColumnCount;
|
||||
int[] syndromes = new int[wordCount];
|
||||
int[] sizes = new int[syndromeCount];
|
||||
|
||||
for(int word = 0; word < wordCount; word++)
|
||||
{
|
||||
syndromes[word] = getSyndrome(word, mRowChecks);
|
||||
|
||||
if(Integer.bitCount(word) <= maximumWeight)
|
||||
{
|
||||
sizes[syndromes[word]]++;
|
||||
}
|
||||
}
|
||||
|
||||
mCosets = new int[syndromeCount][];
|
||||
mMinimumWeights = new int[syndromeCount];
|
||||
|
||||
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
|
||||
{
|
||||
mCosets[syndrome] = new int[sizes[syndrome]];
|
||||
}
|
||||
|
||||
int[] filled = new int[syndromeCount];
|
||||
|
||||
for(int weight = 0; weight <= maximumWeight; weight++)
|
||||
{
|
||||
for(int word = 0; word < wordCount; word++)
|
||||
{
|
||||
if(Integer.bitCount(word) == weight)
|
||||
{
|
||||
int syndrome = syndromes[word];
|
||||
mCosets[syndrome][filled[syndrome]++] = word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(int syndrome = 0; syndrome < syndromeCount; syndrome++)
|
||||
{
|
||||
mMinimumWeights[syndrome] = mCosets[syndrome].length > 0 ? Integer.bitCount(mCosets[syndrome][0]) :
|
||||
maximumWeight + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of bits needed to represent the largest value.
|
||||
*/
|
||||
private static int bitLength(int[] values)
|
||||
{
|
||||
int combined = 0;
|
||||
|
||||
for(int value: values)
|
||||
{
|
||||
combined |= value;
|
||||
}
|
||||
|
||||
return Integer.SIZE - Integer.numberOfLeadingZeros(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the parity check matrix columns for a code from a syndrome calculator, as the syndrome of each single
|
||||
* bit error.
|
||||
* @param length of the code in bits
|
||||
* @param syndrome calculator for a binary message of the code length.
|
||||
* @return parity check matrix column for each bit position.
|
||||
*/
|
||||
public static int[] getParityChecks(int length, ToIntFunction<BinaryMessage> syndrome)
|
||||
{
|
||||
int[] checks = new int[length];
|
||||
|
||||
for(int i = 0; i < length; i++)
|
||||
{
|
||||
BinaryMessage message = new BinaryMessage(length);
|
||||
message.set(i);
|
||||
checks[i] = syndrome.applyAsInt(message);
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the syndrome of the word.
|
||||
* @param word where bit i (LSB = 0) is code bit position i
|
||||
* @param checks parity check matrix column for each bit position
|
||||
* @return syndrome, zero for a valid codeword.
|
||||
*/
|
||||
public static int getSyndrome(int word, int[] checks)
|
||||
{
|
||||
int syndrome = 0;
|
||||
|
||||
while(word != 0)
|
||||
{
|
||||
syndrome ^= checks[Integer.numberOfTrailingZeros(word)];
|
||||
word &= word - 1;
|
||||
}
|
||||
|
||||
return syndrome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if all rows and columns are valid codewords.
|
||||
* @param rows to check
|
||||
* @return true if valid.
|
||||
*/
|
||||
public boolean isValid(int[] rows)
|
||||
{
|
||||
long columnSyndromes = 0;
|
||||
|
||||
for(int row = 0; row < mRowCount; row++)
|
||||
{
|
||||
if(getSyndrome(rows[row], mRowChecks) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
columnSyndromes ^= getColumnContribution(row, rows[row]);
|
||||
}
|
||||
|
||||
return columnSyndromes == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects the rows to the nearest valid product codeword when the number of bit errors doesn't exceed the
|
||||
* maximum error count and the nearest codeword is unique.
|
||||
* @param rows to correct, where bit i of each row is the bit in column i. Unchanged when uncorrectable.
|
||||
* @return number of corrected bit errors, or UNCORRECTABLE.
|
||||
*/
|
||||
public int decode(int[] rows)
|
||||
{
|
||||
if(rows.length != mRowCount)
|
||||
{
|
||||
throw new IllegalArgumentException("Expected " + mRowCount + " rows");
|
||||
}
|
||||
|
||||
Search search = new Search();
|
||||
boolean rowErrors = false;
|
||||
|
||||
for(int row = 0; row < mRowCount; row++)
|
||||
{
|
||||
search.mRowSyndromes[row] = getSyndrome(rows[row], mRowChecks);
|
||||
rowErrors |= search.mRowSyndromes[row] != 0;
|
||||
search.mColumnSyndromes ^= getColumnContribution(row, rows[row]);
|
||||
}
|
||||
|
||||
if(!rowErrors && search.mColumnSyndromes == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
//Lower bound on the error count of the remaining rows, including the column parity rows, at each search depth
|
||||
int lowerBound = 0;
|
||||
|
||||
for(int row = mInformationRowCount; row < mRowCount; row++)
|
||||
{
|
||||
lowerBound += mMinimumWeights[search.mRowSyndromes[row]];
|
||||
}
|
||||
|
||||
search.mLowerBounds[mInformationRowCount] = lowerBound;
|
||||
|
||||
for(int row = mInformationRowCount - 1; row >= 0; row--)
|
||||
{
|
||||
search.mLowerBounds[row] = search.mLowerBounds[row + 1] + mMinimumWeights[search.mRowSyndromes[row]];
|
||||
}
|
||||
|
||||
int start = Math.max(search.mLowerBounds[0], getErrorColumnCount(search.mColumnSyndromes));
|
||||
|
||||
for(search.mBudget = start; search.mBudget <= mMaximumErrors; search.mBudget++)
|
||||
{
|
||||
search(search, 0, 0, 0L);
|
||||
|
||||
if(search.mAborted || search.mSolutionCount > 1)
|
||||
{
|
||||
return UNCORRECTABLE;
|
||||
}
|
||||
|
||||
if(search.mSolutionCount == 1)
|
||||
{
|
||||
for(int row = 0; row < mInformationRowCount; row++)
|
||||
{
|
||||
rows[row] ^= search.mSolution[row];
|
||||
}
|
||||
|
||||
long residual = search.mColumnSyndromes ^ search.mSolutionContribution;
|
||||
|
||||
for(int row = mInformationRowCount; row < mRowCount; row++)
|
||||
{
|
||||
int check = mColumnChecks[row];
|
||||
|
||||
for(int column = 0; column < mColumnCount; column++)
|
||||
{
|
||||
if(((residual >>> (column * mColumnCheckBits)) & check) != 0)
|
||||
{
|
||||
rows[row] ^= 1 << column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return search.mBudget;
|
||||
}
|
||||
}
|
||||
|
||||
return UNCORRECTABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth first search over the information row error patterns, for solutions that don't exceed the error budget.
|
||||
* @param search state
|
||||
* @param row to choose an error pattern for
|
||||
* @param used error count of the error patterns chosen for the preceding rows
|
||||
* @param contribution to the column syndromes from the error patterns chosen for the preceding rows
|
||||
*/
|
||||
private void search(Search search, int row, int used, long contribution)
|
||||
{
|
||||
if(++search.mNodes > MAXIMUM_SEARCH_NODES)
|
||||
{
|
||||
search.mAborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
//The remaining column syndromes must be resolved by the remaining rows
|
||||
long residual = search.mColumnSyndromes ^ contribution;
|
||||
|
||||
if(row == mInformationRowCount)
|
||||
{
|
||||
//Each remaining column syndrome bit is a column parity row error
|
||||
if(used + Long.bitCount(residual) <= search.mBudget && ++search.mSolutionCount == 1)
|
||||
{
|
||||
System.arraycopy(search.mChosen, 0, search.mSolution, 0, mInformationRowCount);
|
||||
search.mSolutionContribution = contribution;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(used + Math.max(search.mLowerBounds[row], getErrorColumnCount(residual)) > search.mBudget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int remainingLowerBound = search.mLowerBounds[row + 1];
|
||||
|
||||
for(int error: mCosets[search.mRowSyndromes[row]])
|
||||
{
|
||||
int weight = Integer.bitCount(error);
|
||||
|
||||
if(used + weight + remainingLowerBound > search.mBudget)
|
||||
{
|
||||
break; //Error patterns are ordered by weight
|
||||
}
|
||||
|
||||
search.mChosen[row] = error;
|
||||
search(search, row + 1, used + weight, contribution ^ getColumnContribution(row, error));
|
||||
|
||||
if(search.mAborted || search.mSolutionCount > 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the contribution of a row word to the column syndromes, where the syndrome for each column occupies
|
||||
* a group of column check bits, starting with column 0 in the LSBs.
|
||||
*/
|
||||
private long getColumnContribution(int row, int word)
|
||||
{
|
||||
long contribution = 0;
|
||||
long check = mColumnChecks[row];
|
||||
|
||||
while(word != 0)
|
||||
{
|
||||
contribution ^= check << (Integer.numberOfTrailingZeros(word) * mColumnCheckBits);
|
||||
word &= word - 1;
|
||||
}
|
||||
|
||||
return contribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of columns with a non-zero syndrome, where each requires at least one more bit error.
|
||||
*/
|
||||
private int getErrorColumnCount(long columnSyndromes)
|
||||
{
|
||||
long folded = columnSyndromes;
|
||||
|
||||
for(int shift = 1; shift < mColumnCheckBits; shift++)
|
||||
{
|
||||
folded |= columnSyndromes >>> shift;
|
||||
}
|
||||
|
||||
return Long.bitCount(folded & mColumnGroupMask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search state for a single decode.
|
||||
*/
|
||||
private final class Search
|
||||
{
|
||||
private final int[] mRowSyndromes = new int[mRowCount];
|
||||
private final int[] mLowerBounds = new int[mInformationRowCount + 1];
|
||||
private final int[] mChosen = new int[mInformationRowCount];
|
||||
private final int[] mSolution = new int[mInformationRowCount];
|
||||
private long mColumnSyndromes;
|
||||
private long mSolutionContribution;
|
||||
private int mBudget;
|
||||
private int mSolutionCount;
|
||||
private long mNodes;
|
||||
private boolean mAborted;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2025 Dennis Sheirer
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.record.AudioRecordingManager;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.settings.MapViewSetting;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import io.github.dsheirer.source.tuner.Tuner;
|
||||
import io.github.dsheirer.source.tuner.TunerEvent;
|
||||
@@ -59,7 +60,8 @@ import io.github.dsheirer.source.tuner.manager.TunerManager;
|
||||
import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager;
|
||||
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
|
||||
import io.github.dsheirer.spectrum.ShowTunerMenuItem;
|
||||
import io.github.dsheirer.spectrum.SpectralDisplayPanel;
|
||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel;
|
||||
import io.github.dsheirer.util.DuplicateLibraryDetector;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import io.github.dsheirer.util.TimeStamp;
|
||||
import io.github.dsheirer.vector.calibrate.CalibrationManager;
|
||||
@@ -81,6 +83,7 @@ import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.prefs.Preferences;
|
||||
import javafx.application.Platform;
|
||||
@@ -153,6 +156,17 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
mApplicationLog = new ApplicationLog(mUserPreferences);
|
||||
mApplicationLog.start();
|
||||
|
||||
Map<String,List<String>> duplicateLibraries = DuplicateLibraryDetector.findDuplicates();
|
||||
|
||||
if(!duplicateLibraries.isEmpty())
|
||||
{
|
||||
mLog.warn("More than one version of these libraries is in the library folder [" +
|
||||
DuplicateLibraryDetector.getFolder(duplicateLibraries) + "]. This happens when a new version of " +
|
||||
"sdrtrunk is unzipped over an older one, and it can load the older library code. Delete the " +
|
||||
"sdrtrunk folder and unzip the release into an empty folder.\n" +
|
||||
DuplicateLibraryDetector.describe(duplicateLibraries));
|
||||
}
|
||||
|
||||
mResourceMonitor = new ResourceMonitor(mUserPreferences);
|
||||
|
||||
ThreadPool.logSettings();
|
||||
@@ -171,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
|
||||
mSettingsManager = new SettingsManager();
|
||||
|
||||
//Older versions stored the map view in the settings file. Move it to the map preferences once.
|
||||
MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView();
|
||||
|
||||
if(legacyMapView != null)
|
||||
{
|
||||
mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(),
|
||||
legacyMapView.getLongitude(), legacyMapView.getZoom());
|
||||
}
|
||||
|
||||
AliasModel aliasModel = new AliasModel();
|
||||
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
|
||||
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);
|
||||
@@ -219,10 +242,13 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
|
||||
}
|
||||
|
||||
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
||||
if(!headless)
|
||||
{
|
||||
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||
}
|
||||
|
||||
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
|
||||
mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
||||
mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
|
||||
mTunerManager.getDiscoveredTunerModel().addListener(this);
|
||||
|
||||
@@ -253,6 +279,11 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
{
|
||||
updateTitle(tuner.getPreferredName());
|
||||
}
|
||||
|
||||
if(!duplicateLibraries.isEmpty())
|
||||
{
|
||||
showDuplicateLibrariesDialog(duplicateLibraries);
|
||||
}
|
||||
}
|
||||
|
||||
if(calibrating && !GraphicsEnvironment.isHeadless())
|
||||
@@ -622,14 +653,14 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
|
||||
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
|
||||
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
|
||||
mLog.info("Stopping spectral display ...");
|
||||
mSpectralPanel.dispose();
|
||||
mJavaFxWindowManager.shutdown();
|
||||
mLog.info("Stopping channels ...");
|
||||
mPlaylistManager.getChannelProcessingManager().shutdown();
|
||||
mAudioRecordingManager.stop();
|
||||
mResourceMonitor.stop();
|
||||
|
||||
mLog.info("Stopping spectral display ...");
|
||||
mSpectralPanel.clearTuner();
|
||||
mLog.info("Stopping tuners ...");
|
||||
mTunerManager.stop();
|
||||
mLog.info("Shutdown complete.");
|
||||
@@ -798,7 +829,7 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
updateTitle(null);
|
||||
break;
|
||||
case NOTIFICATION_SHUTTING_DOWN:
|
||||
Tuner currentTuner = mSpectralPanel.getTuner();
|
||||
Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null;
|
||||
|
||||
if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == null)
|
||||
{
|
||||
@@ -909,6 +940,28 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns the user that the library folder holds more than one version of some libraries, which happens when a new
|
||||
* release is unzipped over an older one. The launch scripts put every jar in the library folder on the class
|
||||
* path, so the older library code can load instead of the newer code.
|
||||
* @param duplicates library name to jar paths, from DuplicateLibraryDetector
|
||||
*/
|
||||
private void showDuplicateLibrariesDialog(Map<String,List<String>> duplicates)
|
||||
{
|
||||
String message = "<html><body style='width: 450px'>" +
|
||||
"<p>The sdrtrunk library folder has more than one version of these libraries:</p><br>" +
|
||||
"<pre>" + DuplicateLibraryDetector.describe(duplicates) + "</pre>" +
|
||||
"<p>This happens when a new version of sdrtrunk is unzipped over an older one. sdrtrunk can load " +
|
||||
"the older library code, which can cause crashes or errors that the newer version fixed.</p><br>" +
|
||||
"<p>Close sdrtrunk, delete the sdrtrunk folder that contains this library folder and unzip the " +
|
||||
"release into a new, empty folder:</p><br>" +
|
||||
"<p><b>" + DuplicateLibraryDetector.getFolder(duplicates) + "</b></p><br>" +
|
||||
"<p>Your playlist and settings are stored in your user folder and won't be affected.</p>" +
|
||||
"</body></html>";
|
||||
|
||||
JOptionPane.showMessageDialog(mMainGui, message, "Duplicate Library Versions", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the application.
|
||||
*/
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.gui.channel;
|
||||
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
|
||||
import io.github.dsheirer.gui.power.SignalPowerView;
|
||||
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
|
||||
import io.github.dsheirer.gui.symbol.SymbolView;
|
||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.module.decode.FeedbackDecoder;
|
||||
import io.github.dsheirer.module.decode.PrimaryDecoder;
|
||||
import io.github.dsheirer.module.decode.am.AMDecoder;
|
||||
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
|
||||
import io.github.dsheirer.source.Source;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
||||
import io.github.dsheirer.spectrum.ComplexDftProcessor;
|
||||
import io.github.dsheirer.spectrum.FrequencyOverlayPanel;
|
||||
import io.github.dsheirer.spectrum.SpectrumPanel;
|
||||
import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter;
|
||||
import io.github.dsheirer.spectrum.converter.DFTResultsConverter;
|
||||
import java.awt.Component;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.ComponentListener;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.List;
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.JFXPanel;
|
||||
import javafx.scene.Scene;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JLayeredPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.event.MouseInputAdapter;
|
||||
|
||||
/**
|
||||
* Display for channel FFT and squelch details
|
||||
*/
|
||||
public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumPanel.class);
|
||||
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
|
||||
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
|
||||
private static final String LABEL_PREFIX_TUNER = "Tuner:";
|
||||
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
|
||||
private static final String LABEL_PREFIX_DECODER = "Decoder:";
|
||||
private final PlaylistManager mPlaylistManager;
|
||||
private ProcessingChain mProcessingChain;
|
||||
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
||||
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
|
||||
private SpectrumPanel mSpectrumPanel;
|
||||
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
|
||||
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
||||
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
|
||||
private final JLabel mTunerPPMLabel;
|
||||
private final JLabel mTunerCorrectionLabel;
|
||||
private final JLabel mChannelCorrectionLabel;
|
||||
private final JLabel mDecoderCorrectionLabel;
|
||||
private final JLabel mDummyLabel = new JLabel(" ");
|
||||
private boolean mPanelVisible = false;
|
||||
private boolean mDftProcessing = false;
|
||||
private final NoiseSquelchView mNoiseSquelchView;
|
||||
private final SignalPowerView mSignalPowerView;
|
||||
private final SymbolView mSymbolView = new SymbolView();
|
||||
private final JFXPanel mNoiseSquelchPanel;
|
||||
private final JFXPanel mSymbolPanel;
|
||||
private JSplitPane mSplitPane;
|
||||
private FeedbackDecoder mFeedbackDecoder;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
|
||||
{
|
||||
mPlaylistManager = playlistManager;
|
||||
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
||||
mSignalPowerView = new SignalPowerView(mPlaylistManager);
|
||||
|
||||
//Register the three swappable right-pane components with the theme manager. Only one is
|
||||
//attached to the split pane at a time, so the LAF-toggle walk of Window.getWindows() can
|
||||
//miss the detached ones - they get refreshed explicitly via registerSwing.
|
||||
ThemeManager.getInstance().registerSwing(mSignalPowerView);
|
||||
setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]"));
|
||||
|
||||
JPanel fftPanel = new JPanel();
|
||||
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
|
||||
|
||||
JPanel labelPanel = new JPanel();
|
||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
|
||||
|
||||
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
|
||||
mTunerPPMLabel.setEnabled(false);
|
||||
labelPanel.add(mTunerPPMLabel);
|
||||
|
||||
labelPanel.add(new JLabel("Corrections (Hz) "));
|
||||
|
||||
mTunerCorrectionLabel = new JLabel(LABEL_PREFIX_TUNER);
|
||||
mTunerCorrectionLabel.setEnabled(false);
|
||||
labelPanel.add(mTunerCorrectionLabel);
|
||||
|
||||
mChannelCorrectionLabel = new JLabel(LABEL_PREFIX_CHANNEL);
|
||||
mChannelCorrectionLabel.setEnabled(false);
|
||||
labelPanel.add(mChannelCorrectionLabel);
|
||||
|
||||
mDecoderCorrectionLabel = new JLabel(LABEL_PREFIX_DECODER);
|
||||
mDecoderCorrectionLabel.setEnabled(false);
|
||||
labelPanel.add(mDecoderCorrectionLabel);
|
||||
|
||||
labelPanel.add(mDummyLabel);
|
||||
|
||||
mNoiseFloorSpinnerModel = new SpinnerNumberModel(18, 8, 36, 1);
|
||||
mNoiseFloorSpinnerModel.addChangeListener(e -> {
|
||||
Number number = mNoiseFloorSpinnerModel.getNumber();
|
||||
mSpectrumPanel.setSampleSize(number.doubleValue());
|
||||
});
|
||||
JSpinner noiseFloorSpinner = new JSpinner(mNoiseFloorSpinnerModel);
|
||||
labelPanel.add(new JLabel("Noise Floor:"));
|
||||
labelPanel.add(noiseFloorSpinner);
|
||||
|
||||
JButton logIndexesButton = new JButton("Log Settings");
|
||||
logIndexesButton.addActionListener(e -> {
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
Source source = mProcessingChain.getSource();
|
||||
|
||||
if(source instanceof PolyphaseChannelSource pcs)
|
||||
{
|
||||
List<Integer> indexes = pcs.getOutputProcessorIndexes();
|
||||
double sampleRate = pcs.getSampleRate();
|
||||
long indexCenterFrequency = pcs.getIndexCenterFrequency();
|
||||
long appliedFrequencyOffset = pcs.getFrequencyOffset();
|
||||
long requestedCenterFrequency = pcs.getFrequency();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Polyphase Channel - BW: ").append(FREQUENCY_FORMAT.format(sampleRate / 1E6d));
|
||||
sb.append(" Center/Requested/Mixer: ").append(FREQUENCY_FORMAT.format(indexCenterFrequency / 1E6d));
|
||||
sb.append("/").append(FREQUENCY_FORMAT.format(requestedCenterFrequency / 1E6d));
|
||||
sb.append("/").append(FREQUENCY_FORMAT.format(appliedFrequencyOffset / 1E6d));
|
||||
sb.append(" Polyphase Indexes: ").append(indexes);
|
||||
sb.append(" Tuner SR:").append(FREQUENCY_FORMAT.format(pcs.getTunerSampleRate() / 1E6d));
|
||||
sb.append(" CF:").append(FREQUENCY_FORMAT.format(pcs.getTunerCenterFrequency() / 1E6d));
|
||||
LOGGER.info(sb.toString());
|
||||
LOGGER.info("Output Processor: " + pcs.getStateDescription());
|
||||
}
|
||||
else if(source instanceof HalfBandTunerChannelSource<?> hbtcs)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Heterodyne Channel - CF:").append(FREQUENCY_FORMAT.format(hbtcs.getFrequency() / 1E6d));
|
||||
sb.append(" SR:").append(FREQUENCY_FORMAT.format(hbtcs.getSampleRate() / 1E6d));
|
||||
sb.append(" Mixer:").append(FREQUENCY_FORMAT.format(hbtcs.getMixerFrequency() / 1E6d));
|
||||
LOGGER.info(sb.toString());
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGGER.info("Unsupported channel type: " + (source != null ? source.getClass() : " null"));
|
||||
}
|
||||
}
|
||||
});
|
||||
//This is a debug button to log the current settings to the app log.
|
||||
// labelPanel.add(mLogIndexesButton);
|
||||
|
||||
fftPanel.add(labelPanel, "wrap");
|
||||
|
||||
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
|
||||
mSpectrumPanel = new SpectrumPanel(settingsManager);
|
||||
mSpectrumPanel.setSampleSize(18.0);
|
||||
|
||||
/**
|
||||
* The layered pane holds the overlapping spectrum and channel panels
|
||||
* and manages the sizing of each panel with the resize listener
|
||||
*/
|
||||
JLayeredPane layeredPanel = new JLayeredPane();
|
||||
layeredPanel.addComponentListener(new ResizeListener());
|
||||
|
||||
/**
|
||||
* Create a mouse adapter to handle mouse events over the spectrum
|
||||
* and waterfall panels
|
||||
*/
|
||||
MouseEventProcessor mouser = new MouseEventProcessor();
|
||||
|
||||
mFrequencyOverlayPanel.addMouseListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseMotionListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseWheelListener(mouser);
|
||||
|
||||
//Add the spectrum and channel panels to the layered panel
|
||||
layeredPanel.add(mSpectrumPanel, 0, 0);
|
||||
layeredPanel.add(mFrequencyOverlayPanel, 1, 0);
|
||||
|
||||
fftPanel.add(layeredPanel);
|
||||
|
||||
mNoiseSquelchPanel = new JFXPanel();
|
||||
mSymbolPanel = new JFXPanel();
|
||||
ThemeManager.getInstance().registerSwing(mNoiseSquelchPanel);
|
||||
ThemeManager.getInstance().registerSwing(mSymbolPanel);
|
||||
|
||||
//Spin noise squelch panel construction off onto the JavafX UI thread.
|
||||
Platform.runLater(() -> {
|
||||
Scene scene = new Scene(mNoiseSquelchView);
|
||||
ThemeManager.getInstance().register(scene);
|
||||
mNoiseSquelchPanel.setScene(scene);
|
||||
Scene scene2 = new Scene(mSymbolView);
|
||||
URL resource = getClass().getResource("/sdrtrunk_style.css");
|
||||
|
||||
if(resource != null)
|
||||
{
|
||||
scene2.getStylesheets().add(resource.toExternalForm());
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
|
||||
}
|
||||
|
||||
ThemeManager.getInstance().register(scene2);
|
||||
mSymbolPanel.setScene(scene2);
|
||||
});
|
||||
|
||||
mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
|
||||
mSplitPane.add(fftPanel, JSplitPane.LEFT);
|
||||
mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT);
|
||||
add(mSplitPane);
|
||||
// mSplitPane.setDividerLocation(0.5);
|
||||
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
|
||||
mComplexDftProcessor.addConverter(DFTResultsConverter);
|
||||
DFTResultsConverter.addListener(mSpectrumPanel);
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pads the value to a fixed width and returns the previx and padded value.
|
||||
* @param prefix of the label
|
||||
* @param value to pad with spaces
|
||||
* @return padded, prefixed label
|
||||
*/
|
||||
private String getPaddedLabel(String prefix, Number value)
|
||||
{
|
||||
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals this panel to indicate if this panel is visible to turn on the FFT processor when the panel is visible
|
||||
* and turn off the FFT processor when it's not.
|
||||
*
|
||||
* Note: this method is intended to be called by the Swing event thread to ensure that only a single thread is
|
||||
* invoking either this method, or the receive() method, since there is no thread synchronization between these
|
||||
* two methods and they each depend on stable access to the mPanelVisible variable.
|
||||
*
|
||||
* @param visible true to indicate that this panel is showing/visible.
|
||||
*/
|
||||
public void setPanelVisible(boolean visible)
|
||||
{
|
||||
mPanelVisible = visible;
|
||||
updateFFTProcessing();
|
||||
mNoiseSquelchView.setShowing(visible);
|
||||
mSymbolView.setShowing(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
|
||||
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
|
||||
*/
|
||||
private void updateFFTProcessing()
|
||||
{
|
||||
if(mPanelVisible && mProcessingChain != null)
|
||||
{
|
||||
startDftProcessing();
|
||||
}
|
||||
else
|
||||
{
|
||||
stopDftProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts DFT processing
|
||||
*/
|
||||
private void startDftProcessing()
|
||||
{
|
||||
if(!mDftProcessing)
|
||||
{
|
||||
mDftProcessing = true;
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
mComplexDftProcessor.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops DFT processing
|
||||
*/
|
||||
private void stopDftProcessing()
|
||||
{
|
||||
if(mDftProcessing)
|
||||
{
|
||||
mSampleStreamTapModule.removeListener();
|
||||
mComplexDftProcessor.stop();
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
mDftProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcast(SourceEvent sourceEvent)
|
||||
{
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
mProcessingChain.broadcast(sourceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets controls when changing processing chain source. Note: this must be called on the Swing
|
||||
* dispatch thread because it directly invokes swing components.
|
||||
*/
|
||||
private void reset()
|
||||
{
|
||||
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
|
||||
mTunerPPMLabel.setEnabled(false);
|
||||
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
|
||||
mTunerCorrectionLabel.setEnabled(false);
|
||||
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
|
||||
mChannelCorrectionLabel.setEnabled(false);
|
||||
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
||||
mDecoderCorrectionLabel.setEnabled(false);
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive notifications of request to provide display of processing chain details.
|
||||
*/
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
//Disconnect the previous processing chain.
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
mNoiseSquelchView.setController(null);
|
||||
mSignalPowerView.setProcessingChain(null);
|
||||
mSymbolView.removeSymbolProvider();
|
||||
mSymbolView.setProtocol("");
|
||||
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
|
||||
mProcessingChain.removeModule(mSampleStreamTapModule);
|
||||
mFeedbackDecoder = null;
|
||||
}
|
||||
|
||||
//Invoking reset - we're on the Swing dispatch thread here
|
||||
reset();
|
||||
|
||||
mProcessingChain = processingChain;
|
||||
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
|
||||
|
||||
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
|
||||
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
|
||||
{
|
||||
setRightComponent(mNoiseSquelchPanel);
|
||||
mNoiseSquelchView.setController(nbfmDecoder);
|
||||
}
|
||||
else if(primaryDecoder instanceof AMDecoder)
|
||||
{
|
||||
setRightComponent(mSignalPowerView);
|
||||
mSignalPowerView.setProcessingChain(mProcessingChain);
|
||||
}
|
||||
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
|
||||
{
|
||||
setRightComponent(mSymbolPanel);
|
||||
mSymbolView.setSymbolProvider(feedbackDecoder);
|
||||
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
|
||||
mFeedbackDecoder = feedbackDecoder;
|
||||
}
|
||||
|
||||
mProcessingChain.addModule(mSampleStreamTapModule);
|
||||
Source source = mProcessingChain.getSource();
|
||||
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
|
||||
}
|
||||
|
||||
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
||||
|
||||
if(channel != null)
|
||||
{
|
||||
List<TunerChannel> tunerChannels = channel.getTunerChannels();
|
||||
|
||||
if(!tunerChannels.isEmpty())
|
||||
{
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFFTProcessing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the component on the right side of the split pane.
|
||||
* @param component to show.
|
||||
*/
|
||||
private void setRightComponent(Component component)
|
||||
{
|
||||
Component rightComponent = mSplitPane.getRightComponent();
|
||||
|
||||
if(rightComponent != component)
|
||||
{
|
||||
mSplitPane.remove(rightComponent);
|
||||
mSplitPane.setRightComponent(component);
|
||||
mSplitPane.setDividerLocation(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processor for source event stream to capture power level and squelch related source events.
|
||||
*/
|
||||
private class SourceEventProcessor implements Listener<SourceEvent>
|
||||
{
|
||||
@Override
|
||||
public void receive(SourceEvent sourceEvent)
|
||||
{
|
||||
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS)
|
||||
{
|
||||
if(sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
|
||||
{
|
||||
//Note: we flip the sign on the error measurement because the value represents the amount of offset the PLL
|
||||
//has to apply to move the signal to center/baseband
|
||||
EventQueue.invokeLater(() -> {
|
||||
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
|
||||
mTunerPPMLabel.setEnabled(true);
|
||||
|
||||
if(status.isAutoPPM())
|
||||
{
|
||||
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
|
||||
}
|
||||
else
|
||||
{
|
||||
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
|
||||
}
|
||||
mTunerCorrectionLabel.setEnabled(true);
|
||||
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
|
||||
mChannelCorrectionLabel.setEnabled(true);
|
||||
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
|
||||
mDecoderCorrectionLabel.setEnabled(true);
|
||||
|
||||
//We se the offset cursor line to negative so that it aligns with where the signal is located
|
||||
//instead of the correction that's needed to bring the signal to zero.
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: is this still needed?
|
||||
mSignalPowerView.receive(sourceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the sizing of the layered pane and resizes the spectrum and
|
||||
* channel panels whenever the layered pane is resized
|
||||
*/
|
||||
public class ResizeListener implements ComponentListener
|
||||
{
|
||||
@Override public void componentResized(ComponentEvent e)
|
||||
{
|
||||
Component c = e.getComponent();
|
||||
|
||||
mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
|
||||
mFrequencyOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
|
||||
}
|
||||
|
||||
@Override public void componentHidden(ComponentEvent arg0) {}
|
||||
@Override public void componentMoved(ComponentEvent arg0) {}
|
||||
@Override public void componentShown(ComponentEvent arg0) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse event handler for the spectral display panel.
|
||||
*/
|
||||
public class MouseEventProcessor extends MouseInputAdapter
|
||||
{
|
||||
@Override public void mouseMoved(MouseEvent event)
|
||||
{
|
||||
update(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cursor display while the mouse is performing actions
|
||||
*/
|
||||
private void update(MouseEvent event)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorLocation(event.getPoint());
|
||||
}
|
||||
|
||||
@Override public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(true);
|
||||
}
|
||||
|
||||
@Override public void mouseExited(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.channel;
|
||||
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.gui.power.SignalPowerView;
|
||||
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
|
||||
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
|
||||
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
|
||||
import io.github.dsheirer.gui.symbol.SymbolView;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.module.decode.FeedbackDecoder;
|
||||
import io.github.dsheirer.module.decode.PrimaryDecoder;
|
||||
import io.github.dsheirer.module.decode.am.AMDecoder;
|
||||
import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
|
||||
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
|
||||
import io.github.dsheirer.source.Source;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.List;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SplitPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Display for channel spectrum, frequency corrections and the decoder squelch, power or symbol view.
|
||||
*
|
||||
* Use this class on the JavaFX application thread.
|
||||
*/
|
||||
public class ChannelSpectrumView extends SplitPane implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelSpectrumView.class);
|
||||
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.00000");
|
||||
private static final String LABEL_PREFIX_PPM = "Tuner PPM: ";
|
||||
private static final String LABEL_PREFIX_TUNER = "Tuner:";
|
||||
private static final String LABEL_PREFIX_CHANNEL = "Channel:";
|
||||
private static final String LABEL_PREFIX_DECODER = "Decoder:";
|
||||
private static final double DIVIDER_POSITION = 0.4;
|
||||
private final PlaylistManager mPlaylistManager;
|
||||
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
||||
private final ChannelSpectralDisplay mChannelSpectralDisplay = new ChannelSpectralDisplay();
|
||||
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
||||
private final Label mTunerPPMLabel = new Label(LABEL_PREFIX_PPM);
|
||||
private final Label mTunerCorrectionLabel = new Label(LABEL_PREFIX_TUNER);
|
||||
private final Label mChannelCorrectionLabel = new Label(LABEL_PREFIX_CHANNEL);
|
||||
private final Label mDecoderCorrectionLabel = new Label(LABEL_PREFIX_DECODER);
|
||||
private final NoiseSquelchView mNoiseSquelchView;
|
||||
private final SignalPowerView mSignalPowerView;
|
||||
private final SymbolView mSymbolView = new SymbolView();
|
||||
private ProcessingChain mProcessingChain;
|
||||
private boolean mPanelVisible = false;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param playlistManager for the channel configurations
|
||||
*/
|
||||
public ChannelSpectrumView(PlaylistManager playlistManager)
|
||||
{
|
||||
mPlaylistManager = playlistManager;
|
||||
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
||||
mSignalPowerView = new SignalPowerView(mPlaylistManager);
|
||||
|
||||
URL resource = getClass().getResource("/sdrtrunk_style.css");
|
||||
|
||||
if(resource != null)
|
||||
{
|
||||
mSymbolView.getStylesheets().add(resource.toExternalForm());
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGGER.warn("Can't find stylesheet resource for sdrtrunk");
|
||||
}
|
||||
|
||||
//Use the same color theme as the channel spectral display
|
||||
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
|
||||
|
||||
Region leftSpacer = new Region();
|
||||
Region rightSpacer = new Region();
|
||||
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
|
||||
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
|
||||
HBox labels = new HBox(10, leftSpacer, mTunerPPMLabel, new Label("Corrections (Hz) "), mTunerCorrectionLabel,
|
||||
mChannelCorrectionLabel, mDecoderCorrectionLabel, rightSpacer);
|
||||
labels.setAlignment(Pos.CENTER);
|
||||
labels.setPadding(new Insets(2));
|
||||
|
||||
VBox.setVgrow(mChannelSpectralDisplay, Priority.ALWAYS);
|
||||
VBox spectrumBox = new VBox(labels, mChannelSpectralDisplay);
|
||||
spectrumBox.setMinWidth(0);
|
||||
|
||||
getItems().addAll(spectrumBox, mNoiseSquelchView);
|
||||
reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pads the value to a fixed width and returns the prefix and padded value.
|
||||
*/
|
||||
private static String getPaddedLabel(String prefix, Number value)
|
||||
{
|
||||
return prefix + StringUtils.leftPad(String.valueOf(value), 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals this view to indicate if it is visible, to turn on the DFT processor when the view is visible and turn
|
||||
* it off when it's not.
|
||||
* @param visible true to indicate that this view is showing/visible.
|
||||
*/
|
||||
public void setPanelVisible(boolean visible)
|
||||
{
|
||||
mPanelVisible = visible;
|
||||
updateFFTProcessing();
|
||||
mNoiseSquelchView.setShowing(visible);
|
||||
mSymbolView.setShowing(visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on DFT processing when there is a processing chain and the user has this view selected and visible.
|
||||
* Otherwise, turns off DFT processing.
|
||||
*/
|
||||
private void updateFFTProcessing()
|
||||
{
|
||||
boolean active = mPanelVisible && mProcessingChain != null;
|
||||
|
||||
if(active)
|
||||
{
|
||||
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
|
||||
}
|
||||
else
|
||||
{
|
||||
mSampleStreamTapModule.removeListener();
|
||||
}
|
||||
|
||||
mChannelSpectralDisplay.setActive(active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets controls when changing processing chain source.
|
||||
*/
|
||||
private void reset()
|
||||
{
|
||||
mTunerPPMLabel.setText(LABEL_PREFIX_PPM);
|
||||
mTunerPPMLabel.setDisable(true);
|
||||
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER);
|
||||
mTunerCorrectionLabel.setDisable(true);
|
||||
mChannelCorrectionLabel.setText(LABEL_PREFIX_CHANNEL);
|
||||
mChannelCorrectionLabel.setDisable(true);
|
||||
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
||||
mDecoderCorrectionLabel.setDisable(true);
|
||||
mChannelSpectralDisplay.setChannel(0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the processing chain details. Call on the JavaFX application thread.
|
||||
* @param processingChain to show, or null to clear the view
|
||||
*/
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
//Disconnect the previous processing chain.
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
mNoiseSquelchView.setController(null);
|
||||
mSignalPowerView.setProcessingChain(null);
|
||||
mSymbolView.removeSymbolProvider();
|
||||
mSymbolView.setProtocol("");
|
||||
mProcessingChain.removeSourceEventListener(mSourceEventProcessor);
|
||||
mProcessingChain.removeModule(mSampleStreamTapModule);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
mProcessingChain = processingChain;
|
||||
|
||||
if(mProcessingChain != null)
|
||||
{
|
||||
mProcessingChain.addSourceEventListener(mSourceEventProcessor);
|
||||
|
||||
PrimaryDecoder primaryDecoder = mProcessingChain.getPrimaryDecoder();
|
||||
|
||||
if(primaryDecoder instanceof NBFMDecoder nbfmDecoder)
|
||||
{
|
||||
setRightComponent(mNoiseSquelchView);
|
||||
mNoiseSquelchView.setController(nbfmDecoder);
|
||||
}
|
||||
else if(primaryDecoder instanceof AMDecoder)
|
||||
{
|
||||
setRightComponent(mSignalPowerView);
|
||||
mSignalPowerView.setProcessingChain(mProcessingChain);
|
||||
}
|
||||
else if(primaryDecoder instanceof FeedbackDecoder feedbackDecoder)
|
||||
{
|
||||
setRightComponent(mSymbolView);
|
||||
mSymbolView.setSymbolProvider(feedbackDecoder);
|
||||
mSymbolView.setProtocol(feedbackDecoder.getProtocolDescription());
|
||||
}
|
||||
|
||||
mProcessingChain.addModule(mSampleStreamTapModule);
|
||||
Source source = mProcessingChain.getSource();
|
||||
|
||||
int bandwidth = 0;
|
||||
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
||||
|
||||
if(channel != null)
|
||||
{
|
||||
List<TunerChannel> tunerChannels = channel.getTunerChannels();
|
||||
|
||||
if(!tunerChannels.isEmpty())
|
||||
{
|
||||
bandwidth = tunerChannels.getFirst().getBandwidth();
|
||||
}
|
||||
}
|
||||
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
mChannelSpectralDisplay.setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
|
||||
}
|
||||
}
|
||||
|
||||
updateFFTProcessing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the node on the right side of the split pane.
|
||||
*/
|
||||
private void setRightComponent(Node node)
|
||||
{
|
||||
if(getItems().size() < 2 || getItems().get(1) != node)
|
||||
{
|
||||
if(getItems().size() > 1)
|
||||
{
|
||||
getItems().remove(1);
|
||||
}
|
||||
|
||||
getItems().add(node);
|
||||
setDividerPositions(DIVIDER_POSITION);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processor for source event stream to capture power level and squelch related source events.
|
||||
*/
|
||||
private class SourceEventProcessor implements Listener<SourceEvent>
|
||||
{
|
||||
@Override
|
||||
public void receive(SourceEvent sourceEvent)
|
||||
{
|
||||
if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_CHANNEL_FREQUENCY_CORRECTION_STATUS &&
|
||||
sourceEvent instanceof ChannelFrequencyCorrectionStatusNotification status)
|
||||
{
|
||||
Platform.runLater(() -> {
|
||||
mTunerPPMLabel.setText(LABEL_PREFIX_PPM + PPM_FORMAT.format(status.getTunerPPM()));
|
||||
mTunerPPMLabel.setDisable(false);
|
||||
|
||||
if(status.isAutoPPM())
|
||||
{
|
||||
mTunerCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_TUNER, status.getTunerCorrection()));
|
||||
}
|
||||
else
|
||||
{
|
||||
mTunerCorrectionLabel.setText(LABEL_PREFIX_TUNER + " disabled");
|
||||
}
|
||||
|
||||
mTunerCorrectionLabel.setDisable(false);
|
||||
mChannelCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_CHANNEL, status.getChannelCorrection()));
|
||||
mChannelCorrectionLabel.setDisable(false);
|
||||
mDecoderCorrectionLabel.setText(getPaddedLabel(LABEL_PREFIX_DECODER, status.getDecoderCorrection()));
|
||||
mDecoderCorrectionLabel.setDisable(false);
|
||||
});
|
||||
|
||||
//The offset cursor line is negative so that it aligns with where the signal is located instead of the
|
||||
//correction that's needed to bring the signal to zero.
|
||||
mChannelSpectralDisplay.setCarrierOffset(-status.getDecoderCorrection());
|
||||
}
|
||||
|
||||
mSignalPowerView.receive(sourceEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
package io.github.dsheirer.gui.control;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Power meter for displaying signal power levels in dB scale with optional peak value and squelch threshold lines.
|
||||
*/
|
||||
public class DbPowerMeter extends JComponent
|
||||
{
|
||||
public static final double DEFAULT_MINIMUM_POWER = -110.0d;
|
||||
public static final double DEFAULT_MAXIMUM_POWER = 0.0d;
|
||||
private static final int BAR_WIDTH = 30;
|
||||
private static final int PADDING = 3;
|
||||
private static final int DOUBLE_PADDING = PADDING * 2;
|
||||
private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0");
|
||||
private static final Color COLOR_BAR = Color.LIGHT_GRAY;
|
||||
private static final Color COLOR_THRESHOLD = Color.BLUE;
|
||||
private static final Color COLOR_PEAK = Color.PINK;
|
||||
|
||||
private double mMinimumValue = DEFAULT_MINIMUM_POWER;
|
||||
private double mMaximumValue = DEFAULT_MAXIMUM_POWER;
|
||||
private double mExtent = mMaximumValue - mMinimumValue;
|
||||
private double mPeak = mMinimumValue;
|
||||
private double mPower = mMinimumValue;
|
||||
private double mSquelchThreshold = mMinimumValue;
|
||||
|
||||
private boolean mPeakVisible = false;
|
||||
private boolean mSquelchThresholdVisible = false;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public DbPowerMeter()
|
||||
{
|
||||
setPreferredSize(new Dimension(90, 100));
|
||||
setBorder(BorderFactory.createTitledBorder("Power (dB)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets peak, power and squelch to minimums
|
||||
*/
|
||||
public void reset()
|
||||
{
|
||||
mPeak = mMinimumValue;
|
||||
mPower = mMinimumValue;
|
||||
mSquelchThreshold = mMinimumValue;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary method for rendering the control
|
||||
*/
|
||||
@Override
|
||||
protected void paintComponent(Graphics g)
|
||||
{
|
||||
super.paintComponent(g);
|
||||
|
||||
//Draw meter border
|
||||
g.setColor(getForeground());
|
||||
g.drawRect(getInsets().left, getInsets().top, BAR_WIDTH, getHeight() - getInsets().top - getInsets().bottom);
|
||||
|
||||
//Draw filled bar
|
||||
g.setColor(COLOR_BAR);
|
||||
int fillHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
|
||||
int height = (int) (fillHeight * getPowerPercent() - PADDING);
|
||||
int top = (fillHeight - height) + getInsets().top + PADDING;
|
||||
g.fillRect(getInsets().left + PADDING, top, BAR_WIDTH - DOUBLE_PADDING, height);
|
||||
|
||||
//Draw threshold
|
||||
if(isSquelchThresholdVisible())
|
||||
{
|
||||
g.setColor(COLOR_THRESHOLD);
|
||||
drawPercentLine(g, getSquelchThresholdPercent());
|
||||
}
|
||||
|
||||
//Draw peak line
|
||||
if(isPeakVisible())
|
||||
{
|
||||
g.setColor(COLOR_PEAK);
|
||||
drawPercentLine(g, getPeakPercent());
|
||||
}
|
||||
|
||||
//Draw legend/scale
|
||||
g.setColor(getForeground());
|
||||
for(double legendValue: getScaleValues())
|
||||
{
|
||||
drawScaleText(g, legendValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line as a percentage of the value range (extent) using the current graphics color.
|
||||
*/
|
||||
private void drawPercentLine(Graphics g, double percentValue)
|
||||
{
|
||||
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
|
||||
int height = (int) (totalHeight * percentValue - PADDING);
|
||||
int top = (totalHeight - height) + getInsets().top + PADDING;
|
||||
int left = getInsets().left;
|
||||
g.drawLine(left, top, left + BAR_WIDTH, top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a tick and text for the value
|
||||
* @param g graphics object
|
||||
* @param value to draw
|
||||
*/
|
||||
private void drawScaleText(Graphics g, double value)
|
||||
{
|
||||
double percent = getPercent(value);
|
||||
int totalHeight = getHeight() - getInsets().top - getInsets().bottom - DOUBLE_PADDING;
|
||||
int height = (int) (totalHeight * percent - PADDING);
|
||||
int top = (totalHeight - height) + getInsets().top + PADDING;
|
||||
int left = getInsets().left + BAR_WIDTH - PADDING;
|
||||
int textOffset = getFontMetrics(getFont()).getMaxAscent() / 2 - 2;
|
||||
String label = DECIMAL_FORMAT.format(value);
|
||||
g.drawString(label, left + DOUBLE_PADDING, top + textOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale values to display, dynamically adapted to the height of the control
|
||||
*/
|
||||
private List<Double> getScaleValues()
|
||||
{
|
||||
double ascent = getFontMetrics(getFont()).getMaxAscent() * 2;
|
||||
double labelCount = getHeight() / ascent;
|
||||
double interval = getExtent() / labelCount;
|
||||
List<Double> values = new ArrayList<>();
|
||||
for(double x = 0; x > getMinimumValue(); x -= interval)
|
||||
{
|
||||
values.add(x);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current power level
|
||||
* @return power level (dB)
|
||||
*/
|
||||
public double getPower()
|
||||
{
|
||||
return mPower;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current power level constrained to the minimum and maximum values for this control.
|
||||
* @param power (dB)
|
||||
*/
|
||||
public void setPower(double power)
|
||||
{
|
||||
if(power > getMaximumValue())
|
||||
{
|
||||
mPower = getMaximumValue();
|
||||
}
|
||||
else if(power < getMinimumValue())
|
||||
{
|
||||
mPower = getMinimumValue();
|
||||
}
|
||||
else
|
||||
{
|
||||
mPower = power;
|
||||
}
|
||||
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Squelch threshold value.
|
||||
* @return threshold (dB)
|
||||
*/
|
||||
public double getSquelchThreshold()
|
||||
{
|
||||
return mSquelchThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch thresold value
|
||||
* @param squelchThreshold (dB)
|
||||
*/
|
||||
public void setSquelchThreshold(double squelchThreshold)
|
||||
{
|
||||
mSquelchThreshold = squelchThreshold;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum displayable power level
|
||||
* @return minimum (dB) - defaults to -110 dB
|
||||
*/
|
||||
public double getMinimumValue()
|
||||
{
|
||||
return mMinimumValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum displayable power level
|
||||
* @param minimumValue (dB)
|
||||
*/
|
||||
public void setMinimumValue(double minimumValue)
|
||||
{
|
||||
mMinimumValue = minimumValue;
|
||||
updateExtent();
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum displayable power level
|
||||
* @return maximum (dB) - defaults to 0 dB
|
||||
*/
|
||||
public double getMaximumValue()
|
||||
{
|
||||
return mMaximumValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum displayable power level
|
||||
* @param maximumValue (dB)
|
||||
*/
|
||||
public void setMaximumValue(double maximumValue)
|
||||
{
|
||||
mMaximumValue = maximumValue;
|
||||
updateExtent();
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the min/max extent
|
||||
*/
|
||||
private void updateExtent()
|
||||
{
|
||||
mExtent = getMaximumValue() - getMinimumValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent or range of displayable values.
|
||||
* @return extent (dB)
|
||||
*/
|
||||
private double getExtent()
|
||||
{
|
||||
return mExtent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak value line
|
||||
* @return peak (dB)
|
||||
*/
|
||||
public double getPeak()
|
||||
{
|
||||
return mPeak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the peak value line
|
||||
* @param peak (db)
|
||||
*/
|
||||
public void setPeak(double peak)
|
||||
{
|
||||
mPeak = peak;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the peak line is set to visible and the current peak value is within the min/max range.
|
||||
*/
|
||||
public boolean isPeakVisible()
|
||||
{
|
||||
return mPeakVisible & isValidValue(getPeak());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the peak line visibility
|
||||
* @param peakVisible value
|
||||
*/
|
||||
public void setPeakVisible(boolean peakVisible)
|
||||
{
|
||||
mPeakVisible = peakVisible;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the squelch threshold line is set to visible and the current squelch threshold value is
|
||||
* within the min/max displayable value range.
|
||||
* @return true if visible.
|
||||
*/
|
||||
public boolean isSquelchThresholdVisible()
|
||||
{
|
||||
return mSquelchThresholdVisible & isValidValue(getSquelchThreshold());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of the squelch threshold line
|
||||
* @param squelchThresholdVisible visibility
|
||||
*/
|
||||
public void setSquelchThresholdVisible(boolean squelchThresholdVisible)
|
||||
{
|
||||
mSquelchThresholdVisible = squelchThresholdVisible;
|
||||
repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage of the min/max extent for the value
|
||||
* @param value to calculate
|
||||
* @return percentage of displayable value range
|
||||
*/
|
||||
private double getPercent(double value)
|
||||
{
|
||||
return (value - getMinimumValue()) / getExtent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Power as percentage of displayable range
|
||||
*/
|
||||
private double getPowerPercent()
|
||||
{
|
||||
return getPercent(mPower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squelch threshold as percentage of displayable range
|
||||
*/
|
||||
private double getSquelchThresholdPercent()
|
||||
{
|
||||
return getPercent(mSquelchThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak as percentage of displayable range
|
||||
*/
|
||||
private double getPeakPercent()
|
||||
{
|
||||
return getPercent(mPeak);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the value is valid, meaning it falls within the min/max range of displayable values.
|
||||
*/
|
||||
private boolean isValidValue(double value)
|
||||
{
|
||||
return getMinimumValue() <= value && value <= getMaximumValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import com.google.common.base.Joiner;
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Alias table cell value: the alias names to show, with the color and icon of the alias that styles the cell.
|
||||
* @param text of the alias names
|
||||
* @param color of the styling alias as a 32-bit RGB integer
|
||||
* @param iconName of the styling alias, or null
|
||||
*/
|
||||
public record AliasDisplay(String text, int color, String iconName) implements Comparable<AliasDisplay>
|
||||
{
|
||||
/**
|
||||
* Creates the display for the aliases, styled by the first alias.
|
||||
* @param aliases to display
|
||||
* @return display or null when there are no aliases.
|
||||
*/
|
||||
public static AliasDisplay of(List<Alias> aliases)
|
||||
{
|
||||
if(aliases == null || aliases.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return of(Joiner.on(", ").skipNulls().join(aliases), aliases.getFirst());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the display for the text, styled by the alias
|
||||
* @param text to display
|
||||
* @param alias to style the display
|
||||
* @return display
|
||||
*/
|
||||
public static AliasDisplay of(String text, Alias alias)
|
||||
{
|
||||
return new AliasDisplay(text, alias.getColor(), alias.getIconName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(AliasDisplay other)
|
||||
{
|
||||
return String.CASE_INSENSITIVE_ORDER.compare(text, other.text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.channel.state.DecoderState;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Activity summary details for the selected channel.
|
||||
*/
|
||||
public class ChannelDetailView extends VBox implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final String EMPTY_DETAILS = "Please select a channel to view details";
|
||||
private final ChannelProcessingManager mChannelProcessingManager;
|
||||
private final Label mSystemLabel = new Label();
|
||||
private final Label mSiteLabel = new Label();
|
||||
private final Label mNameLabel = new Label();
|
||||
private final TextArea mDetailTextArea = new TextArea(EMPTY_DETAILS);
|
||||
private ProcessingChain mProcessingChain;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param channelProcessingManager for the channel of the processing chain
|
||||
*/
|
||||
public ChannelDetailView(ChannelProcessingManager channelProcessingManager)
|
||||
{
|
||||
mChannelProcessingManager = channelProcessingManager;
|
||||
|
||||
Button refreshButton = new Button("Refresh");
|
||||
refreshButton.setTooltip(new Tooltip("Update the channel details"));
|
||||
refreshButton.setOnAction(event -> refresh());
|
||||
|
||||
HBox labels = new HBox(5, new Label("System:"), grow(mSystemLabel), new Label("Site:"), grow(mSiteLabel),
|
||||
new Label("Channel Name:"), grow(mNameLabel), refreshButton);
|
||||
labels.setAlignment(Pos.CENTER_LEFT);
|
||||
labels.setPadding(new Insets(3, 2, 3, 2));
|
||||
|
||||
mDetailTextArea.setEditable(false);
|
||||
VBox.setVgrow(mDetailTextArea, Priority.ALWAYS);
|
||||
getChildren().addAll(labels, mDetailTextArea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the details for the currently selected channel. Call on the JavaFX application thread.
|
||||
*/
|
||||
public void refresh()
|
||||
{
|
||||
receive(mProcessingChain);
|
||||
}
|
||||
|
||||
private static Label grow(Label label)
|
||||
{
|
||||
label.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(label, Priority.ALWAYS);
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the details for the processing chain. Call on the JavaFX application thread.
|
||||
* @param processingChain to show, or null to clear the view
|
||||
*/
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
mProcessingChain = processingChain;
|
||||
|
||||
Channel channel = mChannelProcessingManager.getChannel(processingChain);
|
||||
mSystemLabel.setText(channel != null ? channel.getSystem() : null);
|
||||
mSiteLabel.setText(channel != null ? channel.getSite() : null);
|
||||
mNameLabel.setText(channel != null ?
|
||||
(channel.getChannelType() == Channel.ChannelType.TRAFFIC ? "Traffic Channel" : channel.getName()) : null);
|
||||
|
||||
if(processingChain != null)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for(DecoderState decoderState: processingChain.getDecoderStates())
|
||||
{
|
||||
sb.append(decoderState.getActivitySummary());
|
||||
}
|
||||
|
||||
//Keep the scroll position, so that a refresh doesn't move the user back to the top
|
||||
double scrollTop = mDetailTextArea.getScrollTop();
|
||||
mDetailTextArea.setText(sb.toString());
|
||||
mDetailTextArea.setScrollTop(scrollTop);
|
||||
}
|
||||
else
|
||||
{
|
||||
mDetailTextArea.setText(EMPTY_DETAILS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.channel.metadata.ChannelAndMetadata;
|
||||
import io.github.dsheirer.channel.metadata.ChannelMetadata;
|
||||
import io.github.dsheirer.channel.metadata.ChannelMetadataField;
|
||||
import io.github.dsheirer.channel.metadata.ChannelMetadataModel;
|
||||
import io.github.dsheirer.channel.state.State;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
|
||||
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
|
||||
import io.github.dsheirer.gui.table.TableColumnMonitor;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.decoder.ChannelStateIdentifier;
|
||||
import io.github.dsheirer.identifier.decoder.DecoderLogicalChannelNameIdentifier;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableRow;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Table of the channel metadata for the currently processing channels. Selecting a row broadcasts the processing
|
||||
* chain for the channel to the registered listeners.
|
||||
*/
|
||||
public class ChannelMetadataView extends VBox implements ChannelMetadataModel.Listener
|
||||
{
|
||||
private static final String TABLE_KEY = "now.playing.channels";
|
||||
private static final String SWING_TABLE_KEY = "channel.metadata.panel";
|
||||
private static final String EMPTY_IDENTIFIER = "-----";
|
||||
private static final String EMPTY_STATE = "----";
|
||||
private static final Map<State,String> STATE_STYLES = new EnumMap<>(State.class);
|
||||
|
||||
static
|
||||
{
|
||||
//Colors match the Swing version of the table (java.awt.Color constants)
|
||||
STATE_STYLES.put(State.ACTIVE, style("#00FFFF", "#0000FF"));
|
||||
STATE_STYLES.put(State.CALL, style("#0000FF", "#FFFF00"));
|
||||
STATE_STYLES.put(State.CONTROL, style("#FFC800", "#0000FF"));
|
||||
STATE_STYLES.put(State.DATA, style("#00FF00", "#0000FF"));
|
||||
STATE_STYLES.put(State.ENCRYPTED, style("#FF00FF", "#FFFFFF"));
|
||||
STATE_STYLES.put(State.FADE, style("#C0C0C0", "#404040"));
|
||||
STATE_STYLES.put(State.IDLE, style("#FFFFFF", "#404040"));
|
||||
STATE_STYLES.put(State.RESET, style("#FFAFAF", "#FFFF00"));
|
||||
STATE_STYLES.put(State.TEARDOWN, style("#404040", "#FFFFFF"));
|
||||
}
|
||||
|
||||
private final ChannelProcessingManager mChannelProcessingManager;
|
||||
private final ChannelMetadataModel mChannelMetadataModel;
|
||||
private final IconModel mIconModel;
|
||||
private final TalkgroupFormatPreference mTalkgroupFormatPreference;
|
||||
private final TableView<Row> mTableView = new TableView<>();
|
||||
private final ObservableList<Row> mRows = FXCollections.observableArrayList();
|
||||
//Changed on the JavaFX thread and read on the decoder threads that send channel metadata updates
|
||||
private final Map<ChannelMetadata,Row> mRowMap = new ConcurrentHashMap<>();
|
||||
private final Broadcaster<ProcessingChain> mSelectedProcessingChainBroadcaster = new Broadcaster<>();
|
||||
private final TableColumnMonitor<Row> mTableColumnMonitor;
|
||||
private Channel mUserSelectedChannel;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param channelProcessingManager for the channel metadata and processing chains
|
||||
* @param iconModel for alias icons
|
||||
* @param userPreferences for talkgroup formatting and column settings
|
||||
*/
|
||||
public ChannelMetadataView(ChannelProcessingManager channelProcessingManager, IconModel iconModel,
|
||||
UserPreferences userPreferences)
|
||||
{
|
||||
mChannelProcessingManager = channelProcessingManager;
|
||||
mChannelMetadataModel = mChannelProcessingManager.getChannelMetadataModel();
|
||||
mIconModel = iconModel;
|
||||
mTalkgroupFormatPreference = userPreferences.getTalkgroupFormatPreference();
|
||||
|
||||
mTableView.getColumns().addAll(createColumns());
|
||||
mTableView.setItems(mRows);
|
||||
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
|
||||
mTableView.setPlaceholder(new javafx.scene.control.Label("No channels are processing"));
|
||||
mTableView.setRowFactory(tableView -> createRow());
|
||||
mTableView.getSelectionModel().selectedItemProperty().addListener((observable, oldRow, newRow) -> selected(newRow));
|
||||
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, TABLE_KEY, userPreferences.getSwingPreference(),
|
||||
SWING_TABLE_KEY);
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
HBox toolbar = new HBox(5, spacer, new ColumnConfigurationButton(mTableColumnMonitor, "Now Playing Columns"));
|
||||
toolbar.setAlignment(Pos.CENTER_RIGHT);
|
||||
toolbar.setPadding(new Insets(2, 2, 2, 2));
|
||||
|
||||
VBox.setVgrow(mTableView, Priority.ALWAYS);
|
||||
getChildren().addAll(toolbar, mTableView);
|
||||
|
||||
for(ChannelMetadata channelMetadata: mChannelMetadataModel.addListener(this))
|
||||
{
|
||||
addRow(channelMetadata);
|
||||
}
|
||||
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the listener to receive the processing chain for the channel that the user selects in the table, or null
|
||||
* when the selection is cleared. Notifications arrive on the JavaFX application thread.
|
||||
*/
|
||||
public void addProcessingChainSelectionListener(Listener<ProcessingChain> listener)
|
||||
{
|
||||
mSelectedProcessingChainBroadcaster.addListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares this view for disposal.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
mChannelMetadataModel.removeListener(this);
|
||||
MyEventBus.getGlobalEventBus().unregister(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives preference update notifications via the event bus
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
{
|
||||
//Talkgroup format changes the identifiers and the application preference can change the alias colors
|
||||
if(preferenceType == PreferenceType.TALKGROUP_FORMAT || preferenceType == PreferenceType.APPLICATION)
|
||||
{
|
||||
Platform.runLater(() -> mRows.forEach(Row::refresh));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void added(ChannelAndMetadata channelAndMetadata)
|
||||
{
|
||||
List<ChannelMetadata> channelMetadata = new ArrayList<>(channelAndMetadata.getChannelMetadata());
|
||||
|
||||
Platform.runLater(() -> {
|
||||
for(ChannelMetadata metadata: channelMetadata)
|
||||
{
|
||||
addRow(metadata);
|
||||
}
|
||||
|
||||
//The DMR Capacity+ rest channel rotation converts the selected channel to a traffic channel and restarts
|
||||
//the previous channel. Re-select the row of the restarted channel so that the user isn't chasing it.
|
||||
if(mUserSelectedChannel != null && !channelMetadata.isEmpty() &&
|
||||
mUserSelectedChannel.getChannelID() == channelAndMetadata.getChannel().getChannelID())
|
||||
{
|
||||
Row row = mRowMap.get(channelMetadata.getFirst());
|
||||
|
||||
if(row != null)
|
||||
{
|
||||
mTableView.getSelectionModel().select(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removed(ChannelMetadata channelMetadata)
|
||||
{
|
||||
Platform.runLater(() -> {
|
||||
Row row = mRowMap.remove(channelMetadata);
|
||||
|
||||
if(row != null)
|
||||
{
|
||||
mRows.remove(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updated(ChannelMetadata channelMetadata, ChannelMetadataField channelMetadataField)
|
||||
{
|
||||
Row row = mRowMap.get(channelMetadata);
|
||||
|
||||
if(row != null)
|
||||
{
|
||||
row.scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a row for the channel metadata. Call on the JavaFX application thread.
|
||||
*/
|
||||
private void addRow(ChannelMetadata channelMetadata)
|
||||
{
|
||||
if(!mRowMap.containsKey(channelMetadata))
|
||||
{
|
||||
Row row = new Row(channelMetadata);
|
||||
mRowMap.put(channelMetadata, row);
|
||||
mRows.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void selected(Row row)
|
||||
{
|
||||
ProcessingChain processingChain = null;
|
||||
|
||||
if(row != null)
|
||||
{
|
||||
mUserSelectedChannel = mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata());
|
||||
processingChain = mChannelProcessingManager.getProcessingChain(mUserSelectedChannel);
|
||||
}
|
||||
|
||||
mSelectedProcessingChainBroadcaster.broadcast(processingChain);
|
||||
}
|
||||
|
||||
private TableRow<Row> createRow()
|
||||
{
|
||||
TableRow<Row> tableRow = new TableRow<>();
|
||||
ContextMenu contextMenu = new ContextMenu();
|
||||
contextMenu.setOnShowing(event -> {
|
||||
contextMenu.getItems().clear();
|
||||
Row row = tableRow.getItem();
|
||||
Channel channel = row != null ? mChannelMetadataModel.getChannelFromMetadata(row.getChannelMetadata()) : null;
|
||||
|
||||
if(channel != null)
|
||||
{
|
||||
MenuItem viewChannel = new MenuItem("View/Edit: " + channel.getShortTitle());
|
||||
viewChannel.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
|
||||
contextMenu.getItems().add(viewChannel);
|
||||
}
|
||||
else
|
||||
{
|
||||
MenuItem none = new MenuItem("No Actions Available");
|
||||
none.setDisable(true);
|
||||
contextMenu.getItems().add(none);
|
||||
}
|
||||
});
|
||||
tableRow.setContextMenu(contextMenu);
|
||||
return tableRow;
|
||||
}
|
||||
|
||||
private List<TableColumn<Row,?>> createColumns()
|
||||
{
|
||||
List<TableColumn<Row,?>> columns = new ArrayList<>();
|
||||
|
||||
TableColumn<Row,State> status = NowPlayingCells.column("status", "Status", null, Pos.CENTER,
|
||||
row -> {
|
||||
ChannelStateIdentifier identifier = row.getChannelMetadata().getChannelStateIdentifier();
|
||||
return identifier != null ? identifier.getValue() : null;
|
||||
}, Row::revisionProperty);
|
||||
status.setCellFactory(column -> new NowPlayingCells.AlignedCell<>(column)
|
||||
{
|
||||
@Override
|
||||
protected void updateItem(State state, boolean empty)
|
||||
{
|
||||
super.updateItem(state, empty);
|
||||
|
||||
if(empty)
|
||||
{
|
||||
setText(null);
|
||||
setStyle(null);
|
||||
}
|
||||
else if(state == null)
|
||||
{
|
||||
setText(EMPTY_STATE);
|
||||
setStyle(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
setText(state.getDisplayValue());
|
||||
setStyle(STATE_STYLES.get(state));
|
||||
}
|
||||
}
|
||||
});
|
||||
columns.add(status);
|
||||
|
||||
columns.add(NowPlayingCells.column("decoder", "Decoder", null, Pos.CENTER,
|
||||
row -> toText(row.getChannelMetadata().getDecoderTypeConfigurationIdentifier()), Row::revisionProperty));
|
||||
|
||||
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
|
||||
row -> {
|
||||
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getFromIdentifier());
|
||||
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
|
||||
}, Row::revisionProperty));
|
||||
|
||||
//Talker alias sent over the air by the radio (e.g. DMR or P25)
|
||||
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
|
||||
row -> {
|
||||
Identifier talkerAlias = row.getChannelMetadata().getTalkerAliasIdentifier();
|
||||
return talkerAlias != null && talkerAlias.getValue() != null ? talkerAlias.getValue().toString() : null;
|
||||
}, Row::revisionProperty));
|
||||
|
||||
TableColumn<Row,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias", "From Alias",
|
||||
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getFromIdentifierAliases()),
|
||||
Row::revisionProperty);
|
||||
fromAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
|
||||
columns.add(fromAlias);
|
||||
|
||||
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
|
||||
row -> {
|
||||
String text = mTalkgroupFormatPreference.format(row.getChannelMetadata().getToIdentifier());
|
||||
return text == null || text.isEmpty() ? EMPTY_IDENTIFIER : text;
|
||||
}, Row::revisionProperty));
|
||||
|
||||
TableColumn<Row,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias", "To Alias",
|
||||
Pos.CENTER_LEFT, row -> AliasDisplay.of(row.getChannelMetadata().getToIdentifierAliases()),
|
||||
Row::revisionProperty);
|
||||
toAlias.setCellFactory(NowPlayingCells.alias(mIconModel));
|
||||
columns.add(toAlias);
|
||||
|
||||
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
|
||||
row -> getLogicalChannelName(row.getChannelMetadata()), Row::revisionProperty));
|
||||
|
||||
TableColumn<Row,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null, Pos.CENTER,
|
||||
row -> row.getChannelMetadata().getFrequencyConfigurationIdentifier() != null ?
|
||||
row.getChannelMetadata().getFrequencyConfigurationIdentifier().getValue() : null,
|
||||
Row::revisionProperty);
|
||||
DecimalFormat frequencyFormat = new DecimalFormat("#.00000");
|
||||
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
|
||||
columns.add(frequency);
|
||||
|
||||
columns.add(NowPlayingCells.column("channel.name", "Channel Name", null, Pos.CENTER,
|
||||
row -> toText(row.getChannelMetadata().getChannelNameConfigurationIdentifier()), Row::revisionProperty));
|
||||
|
||||
//The channel rows are in channel start order, the same as the Swing version of the table
|
||||
for(TableColumn<Row,?> column: columns)
|
||||
{
|
||||
column.setSortable(false);
|
||||
}
|
||||
|
||||
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
|
||||
String[] swingColumns = {"status", "decoder", "from", "from.alias", "to", "to.alias", "channel", "frequency",
|
||||
"channel.name"};
|
||||
|
||||
for(TableColumn<Row,?> column: columns)
|
||||
{
|
||||
int swingColumn = Arrays.asList(swingColumns).indexOf(column.getId());
|
||||
|
||||
if(swingColumn >= 0)
|
||||
{
|
||||
TableColumnMonitor.setLegacyIndex(column, swingColumn);
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoder logical channel name with the timeslot, if any
|
||||
*/
|
||||
private static String getLogicalChannelName(ChannelMetadata channelMetadata)
|
||||
{
|
||||
DecoderLogicalChannelNameIdentifier id = channelMetadata.getDecoderLogicalChannelNameIdentifier();
|
||||
|
||||
if(channelMetadata.hasTimeslot())
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return "TS:" + channelMetadata.getTimeslot();
|
||||
}
|
||||
|
||||
return id.getValue().replace(" TS1", "").replace(" TS2", "") + " TS:" + channelMetadata.getTimeslot();
|
||||
}
|
||||
|
||||
return id != null ? id.getValue() : null;
|
||||
}
|
||||
|
||||
private static String toText(Identifier identifier)
|
||||
{
|
||||
return identifier != null ? identifier.toString() : null;
|
||||
}
|
||||
|
||||
private static String style(String background, String foreground)
|
||||
{
|
||||
return "-fx-background-color: " + background + "; -fx-text-fill: " + foreground + ";";
|
||||
}
|
||||
|
||||
/**
|
||||
* Table row item for a channel metadata. The revision increments each time the channel metadata changes so that
|
||||
* the row cells update.
|
||||
*/
|
||||
public static class Row
|
||||
{
|
||||
private final ChannelMetadata mChannelMetadata;
|
||||
private final IntegerProperty mRevision = new SimpleIntegerProperty();
|
||||
private final AtomicBoolean mRefreshPending = new AtomicBoolean();
|
||||
|
||||
Row(ChannelMetadata channelMetadata)
|
||||
{
|
||||
mChannelMetadata = channelMetadata;
|
||||
}
|
||||
|
||||
public ChannelMetadata getChannelMetadata()
|
||||
{
|
||||
return mChannelMetadata;
|
||||
}
|
||||
|
||||
public IntegerProperty revisionProperty()
|
||||
{
|
||||
return mRevision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the row cells. Call on the JavaFX application thread.
|
||||
*/
|
||||
void refresh()
|
||||
{
|
||||
mRevision.set(mRevision.get() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a row cell update on the JavaFX application thread. Can be called from any thread. Updates
|
||||
* that arrive while one is pending are combined.
|
||||
*/
|
||||
void scheduleRefresh()
|
||||
{
|
||||
if(mRefreshPending.compareAndSet(false, true))
|
||||
{
|
||||
Platform.runLater(() -> {
|
||||
mRefreshPending.set(false);
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.channel.IChannelDescriptor;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.filter.FilterSet;
|
||||
import io.github.dsheirer.gui.table.TableColumnMonitor;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.identifier.Form;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||
import io.github.dsheirer.identifier.Role;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.module.decode.event.DecodeEventHistory;
|
||||
import io.github.dsheirer.module.decode.event.IDecodeEvent;
|
||||
import io.github.dsheirer.module.decode.event.filter.DecodeEventFilterSet;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.TableColumn;
|
||||
|
||||
/**
|
||||
* Table of the decode events for the selected channel.
|
||||
*/
|
||||
public class DecodeEventView extends HistoryTableView<IDecodeEvent,IDecodeEvent> implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final String TABLE_KEY = "now.playing.events";
|
||||
private static final String SWING_TABLE_KEY = "decode.event.panel";
|
||||
private final TimestampFormatter mTimestampFormatter;
|
||||
private final FilterSet<IDecodeEvent> mFilterSet = new DecodeEventFilterSet();
|
||||
private DecodeEventHistory mCurrentEventHistory;
|
||||
private Listener<IDecodeEvent> mCurrentEventListener;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param iconModel for alias icons
|
||||
* @param userPreferences for display formats and column settings
|
||||
* @param aliasModel for aliasing identifiers
|
||||
*/
|
||||
public DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel)
|
||||
{
|
||||
this(iconModel, userPreferences, aliasModel, new TimestampFormatter(userPreferences));
|
||||
}
|
||||
|
||||
private DecodeEventView(IconModel iconModel, UserPreferences userPreferences, AliasModel aliasModel,
|
||||
TimestampFormatter timestampFormatter)
|
||||
{
|
||||
super(createColumns(iconModel, userPreferences, aliasModel, timestampFormatter), TABLE_KEY,
|
||||
userPreferences.getSwingPreference(), SWING_TABLE_KEY, "Event Filter Editor", "Event Columns");
|
||||
mTimestampFormatter = timestampFormatter;
|
||||
getHistoryControls().setFilterSet(mFilterSet);
|
||||
//Filter changes come from the Swing filter editor
|
||||
mFilterSet.register(() -> Platform.runLater(this::refilter));
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares this view for disposal
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
MyEventBus.getGlobalEventBus().unregister(this);
|
||||
receive(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives preference update notifications via the event bus
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
{
|
||||
if(preferenceType == PreferenceType.DECODE_EVENT || preferenceType == PreferenceType.TALKGROUP_FORMAT ||
|
||||
preferenceType == PreferenceType.APPLICATION)
|
||||
{
|
||||
Platform.runLater(this::refreshAll);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean include(IDecodeEvent event)
|
||||
{
|
||||
return mFilterSet.canProcess(event) && mFilterSet.passes(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the decode events for the processing chain. Call on the JavaFX application thread.
|
||||
* @param processingChain to show, or null to clear the view
|
||||
*/
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
if(mCurrentEventHistory != null)
|
||||
{
|
||||
mCurrentEventHistory.removeListener(mCurrentEventListener);
|
||||
}
|
||||
|
||||
mCurrentEventHistory = null;
|
||||
mCurrentEventListener = null;
|
||||
|
||||
if(processingChain != null)
|
||||
{
|
||||
DecodeEventHistory history = processingChain.getDecodeEventHistory();
|
||||
//A listener for each history so that events from the previous channel are ignored after a change
|
||||
Listener<IDecodeEvent> listener = new Listener<>()
|
||||
{
|
||||
@Override
|
||||
public void receive(IDecodeEvent event)
|
||||
{
|
||||
Platform.runLater(() -> {
|
||||
if(mCurrentEventListener == this)
|
||||
{
|
||||
add(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
mCurrentEventHistory = history;
|
||||
mCurrentEventListener = listener;
|
||||
setItems(history.getItems());
|
||||
history.addListener(listener);
|
||||
getHistoryControls().setControlsEnabled(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
clear();
|
||||
getHistoryControls().setControlsEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void refreshAll()
|
||||
{
|
||||
mTimestampFormatter.update();
|
||||
super.refreshAll();
|
||||
}
|
||||
|
||||
private static List<TableColumn<Row<IDecodeEvent>,?>> createColumns(IconModel iconModel,
|
||||
UserPreferences userPreferences,
|
||||
AliasModel aliasModel,
|
||||
TimestampFormatter timestampFormatter)
|
||||
{
|
||||
List<TableColumn<Row<IDecodeEvent>,?>> columns = new ArrayList<>();
|
||||
|
||||
TableColumn<Row<IDecodeEvent>,Long> time = NowPlayingCells.column("time", "Time", null, Pos.CENTER,
|
||||
row -> row.getItem().getTimeStart(), Row::revisionProperty);
|
||||
time.setCellFactory(NowPlayingCells.formatted(timestampFormatter::format));
|
||||
columns.add(time);
|
||||
|
||||
TableColumn<Row<IDecodeEvent>,Long> duration = NowPlayingCells.column("duration", "Duration", null,
|
||||
Pos.CENTER, row -> row.getItem().getDuration(), Row::revisionProperty);
|
||||
DecimalFormat durationFormat = new DecimalFormat("0.0");
|
||||
duration.setCellFactory(NowPlayingCells.formatted(value -> value > 0 ? durationFormat.format(value / 1e3d) : null));
|
||||
columns.add(duration);
|
||||
|
||||
columns.add(NowPlayingCells.column("event", "Event", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getEventType() != null ? row.getItem().getEventType().getLabel() : null,
|
||||
Row::revisionProperty));
|
||||
|
||||
columns.add(NowPlayingCells.column("from", "From", null, Pos.CENTER,
|
||||
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.FROM, userPreferences),
|
||||
Row::revisionProperty));
|
||||
|
||||
//Talker alias sent over the air by the radio (e.g. DMR, P25 or NXDN)
|
||||
columns.add(NowPlayingCells.column("talker", "Talker", null, Pos.CENTER,
|
||||
row -> getTalkerAlias(row.getItem().getIdentifierCollection()), Row::revisionProperty));
|
||||
|
||||
TableColumn<Row<IDecodeEvent>,AliasDisplay> fromAlias = NowPlayingCells.column("from.alias", "Alias",
|
||||
"From Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.FROM, aliasModel),
|
||||
Row::revisionProperty);
|
||||
fromAlias.setCellFactory(NowPlayingCells.alias(iconModel));
|
||||
columns.add(fromAlias);
|
||||
|
||||
columns.add(NowPlayingCells.column("to", "To", null, Pos.CENTER,
|
||||
row -> formatIdentifiers(row.getItem().getIdentifierCollection(), Role.TO, userPreferences),
|
||||
Row::revisionProperty));
|
||||
|
||||
TableColumn<Row<IDecodeEvent>,AliasDisplay> toAlias = NowPlayingCells.column("to.alias", "Alias",
|
||||
"To Alias", Pos.CENTER, row -> getAliases(row.getItem().getIdentifierCollection(), Role.TO, aliasModel),
|
||||
Row::revisionProperty);
|
||||
toAlias.setCellFactory(NowPlayingCells.alias(iconModel));
|
||||
columns.add(toAlias);
|
||||
|
||||
columns.add(NowPlayingCells.column("channel", "Channel", null, Pos.CENTER,
|
||||
row -> getChannel(row.getItem()), Row::revisionProperty));
|
||||
|
||||
TableColumn<Row<IDecodeEvent>,Long> frequency = NowPlayingCells.column("frequency", "Frequency", null,
|
||||
Pos.CENTER, row -> {
|
||||
IChannelDescriptor descriptor = row.getItem().getChannelDescriptor();
|
||||
return descriptor != null && descriptor.getDownlinkFrequency() > 0 ?
|
||||
descriptor.getDownlinkFrequency() : null;
|
||||
}, Row::revisionProperty);
|
||||
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
|
||||
frequency.setCellFactory(NowPlayingCells.formatted(value -> frequencyFormat.format(value / 1e6d)));
|
||||
columns.add(frequency);
|
||||
|
||||
columns.add(NowPlayingCells.column("details", "Details", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getDetails(), Row::revisionProperty));
|
||||
|
||||
//Column numbers in the Swing version of the table, for its stored column widths. Talker is a new column.
|
||||
List<String> swingColumns = List.of("time", "duration", "event", "from", "from.alias", "to", "to.alias",
|
||||
"channel", "frequency", "details");
|
||||
|
||||
for(TableColumn<Row<IDecodeEvent>,?> column: columns)
|
||||
{
|
||||
if(swingColumns.contains(column.getId()))
|
||||
{
|
||||
TableColumnMonitor.setLegacyIndex(column, swingColumns.indexOf(column.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Talker alias(es) in the identifier collection
|
||||
*/
|
||||
private static String getTalkerAlias(IdentifierCollection identifierCollection)
|
||||
{
|
||||
if(identifierCollection == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> aliases = new ArrayList<>();
|
||||
|
||||
for(Identifier identifier: identifierCollection.getIdentifiers(Form.TALKER_ALIAS))
|
||||
{
|
||||
if(identifier.getValue() != null)
|
||||
{
|
||||
aliases.add(identifier.getValue().toString());
|
||||
}
|
||||
}
|
||||
|
||||
return aliases.isEmpty() ? null : String.join(",", aliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiers with the role, without talker aliases, which have their own column
|
||||
*/
|
||||
private static List<Identifier> getIdentifiers(IdentifierCollection identifierCollection, Role role)
|
||||
{
|
||||
List<Identifier> identifiers = new ArrayList<>(identifierCollection.getIdentifiers(role));
|
||||
identifiers.removeIf(identifier -> identifier.getForm() == Form.TALKER_ALIAS);
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event timestamp formatter that uses the timestamp format preference.
|
||||
*/
|
||||
private static class TimestampFormatter
|
||||
{
|
||||
private final UserPreferences mUserPreferences;
|
||||
private SimpleDateFormat mFormat;
|
||||
|
||||
TimestampFormatter(UserPreferences userPreferences)
|
||||
{
|
||||
mUserPreferences = userPreferences;
|
||||
update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the format from the preference
|
||||
*/
|
||||
void update()
|
||||
{
|
||||
mFormat = mUserPreferences.getDecodeEventPreference().getTimestampFormat().getFormatter();
|
||||
}
|
||||
|
||||
String format(long timestamp)
|
||||
{
|
||||
return mFormat.format(new Date(timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel descriptor and timeslot for the event
|
||||
*/
|
||||
private static String getChannel(IDecodeEvent event)
|
||||
{
|
||||
IChannelDescriptor channelDescriptor = event.getChannelDescriptor();
|
||||
boolean timeslot = event.hasTimeslot() && !event.toString().contains("TS");
|
||||
|
||||
if(channelDescriptor != null)
|
||||
{
|
||||
return timeslot ? channelDescriptor + " TS" + event.getTimeslot() : channelDescriptor.toString();
|
||||
}
|
||||
|
||||
return timeslot ? "TS" + event.getTimeslot() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the identifiers for the role as a comma separated list
|
||||
*/
|
||||
private static String formatIdentifiers(IdentifierCollection identifierCollection, Role role,
|
||||
UserPreferences userPreferences)
|
||||
{
|
||||
if(identifierCollection == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
|
||||
|
||||
if(identifiers == null || identifiers.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for(Identifier identifier: identifiers)
|
||||
{
|
||||
if(!sb.isEmpty())
|
||||
{
|
||||
sb.append(",");
|
||||
}
|
||||
|
||||
if(identifier.getForm() == Form.TALKGROUP || identifier.getForm() == Form.RADIO ||
|
||||
identifier.getForm() == Form.PATCH_GROUP)
|
||||
{
|
||||
sb.append(userPreferences.getTalkgroupFormatPreference().format(identifier));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aliases for the identifiers with the role. The display is styled by the first alias of the last aliased
|
||||
* identifier, the same as the Swing version of the table.
|
||||
*/
|
||||
private static AliasDisplay getAliases(IdentifierCollection identifierCollection, Role role, AliasModel aliasModel)
|
||||
{
|
||||
if(identifierCollection == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Identifier> identifiers = getIdentifiers(identifierCollection, role);
|
||||
|
||||
if(identifiers == null || identifiers.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AliasList aliasList = aliasModel.getAliasList(identifierCollection);
|
||||
|
||||
if(aliasList == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Alias styleAlias = null;
|
||||
|
||||
for(Identifier identifier: identifiers)
|
||||
{
|
||||
List<Alias> aliases = aliasList.getAliases(identifier);
|
||||
|
||||
if(!aliases.isEmpty())
|
||||
{
|
||||
if(!sb.isEmpty())
|
||||
{
|
||||
sb.append(",");
|
||||
}
|
||||
|
||||
sb.append(Joiner.on(", ").skipNulls().join(aliases));
|
||||
styleAlias = aliases.getFirst();
|
||||
}
|
||||
}
|
||||
|
||||
return styleAlias != null ? AliasDisplay.of(sb.toString(), styleAlias) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.filter.FilterEditor;
|
||||
import io.github.dsheirer.filter.FilterSet;
|
||||
import io.github.dsheirer.gui.table.ColumnConfigurationButton;
|
||||
import io.github.dsheirer.gui.table.TableColumnMonitor;
|
||||
import java.awt.EventQueue;
|
||||
import java.util.function.IntConsumer;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
/**
|
||||
* Toolbar for a history table: filter editor, clear, history size and column configuration controls.
|
||||
*/
|
||||
public class HistoryControls<T> extends HBox
|
||||
{
|
||||
/**
|
||||
* Default number of items in the history
|
||||
*/
|
||||
public static final int DEFAULT_HISTORY_SIZE = 200;
|
||||
private static final int MAXIMUM_HISTORY_SIZE = 2000;
|
||||
|
||||
private final String mFilterEditorTitle;
|
||||
private final Button mFilterButton = new Button("Filters");
|
||||
private final Button mClearButton = new Button("Clear");
|
||||
private final Label mHistoryLabel = new Label("History:");
|
||||
private final Slider mHistorySlider = new Slider(0, MAXIMUM_HISTORY_SIZE, DEFAULT_HISTORY_SIZE);
|
||||
private final Label mHistoryValueLabel = new Label(String.valueOf(DEFAULT_HISTORY_SIZE));
|
||||
//Swing filter editor, accessed on the Swing event thread
|
||||
private FilterEditor<T> mFilterEditor;
|
||||
private FilterSet<T> mFilterSet;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param filterEditorTitle for the filter editor window
|
||||
* @param clear action for the clear button
|
||||
* @param historySizeListener to receive history size changes
|
||||
* @param tableColumnMonitor for the column configuration button
|
||||
* @param columnsTitle for the column configuration editor
|
||||
*/
|
||||
public HistoryControls(String filterEditorTitle, Runnable clear, IntConsumer historySizeListener,
|
||||
TableColumnMonitor<?> tableColumnMonitor, String columnsTitle)
|
||||
{
|
||||
super(5);
|
||||
mFilterEditorTitle = filterEditorTitle;
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setPadding(new Insets(3, 2, 3, 2));
|
||||
|
||||
mFilterButton.setTooltip(new Tooltip("Edit filters"));
|
||||
mFilterButton.setOnAction(event -> showFilterEditor());
|
||||
|
||||
mClearButton.setTooltip(new Tooltip("Clears the history"));
|
||||
mClearButton.setOnAction(event -> clear.run());
|
||||
|
||||
mHistorySlider.setTooltip(new Tooltip("Adjust history size. Double-click to reset to default " +
|
||||
DEFAULT_HISTORY_SIZE));
|
||||
mHistorySlider.setBlockIncrement(25);
|
||||
mHistorySlider.setPrefWidth(200);
|
||||
mHistorySlider.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
int size = newValue.intValue();
|
||||
mHistoryValueLabel.setText(String.valueOf(size));
|
||||
historySizeListener.accept(size);
|
||||
});
|
||||
mHistorySlider.setOnMouseClicked(event -> {
|
||||
if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2)
|
||||
{
|
||||
mHistorySlider.setValue(DEFAULT_HISTORY_SIZE);
|
||||
}
|
||||
});
|
||||
mHistoryValueLabel.setMinWidth(40);
|
||||
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
getChildren().addAll(mFilterButton, mClearButton, mHistoryLabel, mHistorySlider, mHistoryValueLabel, spacer,
|
||||
new ColumnConfigurationButton(tableColumnMonitor, columnsTitle));
|
||||
setControlsEnabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the enabled state of the history controls. The column configuration button is always enabled.
|
||||
*/
|
||||
public void setControlsEnabled(boolean enabled)
|
||||
{
|
||||
mFilterButton.setDisable(!enabled);
|
||||
mClearButton.setDisable(!enabled);
|
||||
mHistoryLabel.setDisable(!enabled);
|
||||
mHistorySlider.setDisable(!enabled);
|
||||
mHistoryValueLabel.setDisable(!enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the filter set for the filter editor.
|
||||
* @param filterSet to edit, or null
|
||||
*/
|
||||
public void setFilterSet(FilterSet<T> filterSet)
|
||||
{
|
||||
mFilterSet = filterSet;
|
||||
|
||||
EventQueue.invokeLater(() -> {
|
||||
if(mFilterEditor != null && filterSet != null)
|
||||
{
|
||||
mFilterEditor.updateFilterSet(filterSet);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the filter editor. The filter editor is a Swing window.
|
||||
*/
|
||||
private void showFilterEditor()
|
||||
{
|
||||
FilterSet<T> filterSet = mFilterSet;
|
||||
|
||||
if(filterSet != null)
|
||||
{
|
||||
EventQueue.invokeLater(() -> {
|
||||
if(mFilterEditor == null)
|
||||
{
|
||||
mFilterEditor = new FilterEditor<>(mFilterEditorTitle, null, filterSet);
|
||||
}
|
||||
|
||||
mFilterEditor.setVisible(true);
|
||||
mFilterEditor.toFront();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.gui.table.TableColumnMonitor;
|
||||
import io.github.dsheirer.preference.swing.SwingPreference;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.collections.transformation.SortedList;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Table of a history of items, newest first, with filter, clear, history size and column configuration controls.
|
||||
*
|
||||
* Use this class on the JavaFX application thread.
|
||||
*
|
||||
* @param <T> type of the history items
|
||||
* @param <F> type of the filtered objects
|
||||
*/
|
||||
public abstract class HistoryTableView<T,F> extends VBox
|
||||
{
|
||||
private final ObservableList<Row<T>> mRows = FXCollections.observableArrayList();
|
||||
private final FilteredList<Row<T>> mFilteredRows = new FilteredList<>(mRows);
|
||||
private final Map<T,Row<T>> mRowMap = new HashMap<>();
|
||||
private final TableView<Row<T>> mTableView = new TableView<>();
|
||||
private final TableColumnMonitor<Row<T>> mTableColumnMonitor;
|
||||
private final HistoryControls<F> mHistoryControls;
|
||||
private int mHistorySize = HistoryControls.DEFAULT_HISTORY_SIZE;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param columns for the table
|
||||
* @param tableKey for the stored column settings
|
||||
* @param swingPreference with the legacy Swing column widths
|
||||
* @param swingTableKey of the legacy Swing table
|
||||
* @param filterEditorTitle for the filter editor window
|
||||
* @param columnsTitle for the column configuration editor window
|
||||
*/
|
||||
protected HistoryTableView(List<TableColumn<Row<T>,?>> columns, String tableKey, SwingPreference swingPreference,
|
||||
String swingTableKey, String filterEditorTitle, String columnsTitle)
|
||||
{
|
||||
mTableView.getColumns().addAll(columns);
|
||||
SortedList<Row<T>> sortedRows = new SortedList<>(mFilteredRows);
|
||||
sortedRows.comparatorProperty().bind(mTableView.comparatorProperty());
|
||||
mTableView.setItems(sortedRows);
|
||||
mTableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_SUBSEQUENT_COLUMNS);
|
||||
mTableView.setPlaceholder(new Label(""));
|
||||
mTableColumnMonitor = new TableColumnMonitor<>(mTableView, tableKey, swingPreference, swingTableKey);
|
||||
mHistoryControls = new HistoryControls<>(filterEditorTitle, this::clear, this::setHistorySize,
|
||||
mTableColumnMonitor, columnsTitle);
|
||||
|
||||
VBox.setVgrow(mTableView, Priority.ALWAYS);
|
||||
getChildren().addAll(mHistoryControls, mTableView);
|
||||
refilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the item passes the current filters
|
||||
*/
|
||||
protected abstract boolean include(T item);
|
||||
|
||||
/**
|
||||
* History controls toolbar
|
||||
*/
|
||||
protected HistoryControls<F> getHistoryControls()
|
||||
{
|
||||
return mHistoryControls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the filters to the items, after a filter change.
|
||||
*/
|
||||
protected void refilter()
|
||||
{
|
||||
mFilteredRows.setPredicate(row -> include(row.getItem()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the item to the top of the history, or updates the row of the item when it is already in the history.
|
||||
* Removes the oldest items when the history is larger than the history size.
|
||||
*/
|
||||
protected void add(T item)
|
||||
{
|
||||
Row<T> row = mRowMap.get(item);
|
||||
|
||||
if(row != null)
|
||||
{
|
||||
//Replace the row with itself so that the filter re-checks the updated item
|
||||
int index = mRows.indexOf(row);
|
||||
|
||||
if(index >= 0)
|
||||
{
|
||||
mRows.set(index, row);
|
||||
}
|
||||
|
||||
row.refresh();
|
||||
}
|
||||
else
|
||||
{
|
||||
row = new Row<>(item);
|
||||
mRowMap.put(item, row);
|
||||
mRows.addFirst(row);
|
||||
trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the history with the items, oldest first.
|
||||
*/
|
||||
protected void setItems(List<T> items)
|
||||
{
|
||||
clear();
|
||||
|
||||
for(T item: items)
|
||||
{
|
||||
add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all items from the history
|
||||
*/
|
||||
protected void clear()
|
||||
{
|
||||
mRows.clear();
|
||||
mRowMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cells of all rows, after a change to the display formats.
|
||||
*/
|
||||
protected void refreshAll()
|
||||
{
|
||||
mRows.forEach(Row::refresh);
|
||||
}
|
||||
|
||||
private void setHistorySize(int historySize)
|
||||
{
|
||||
mHistorySize = historySize;
|
||||
trim();
|
||||
}
|
||||
|
||||
private void trim()
|
||||
{
|
||||
while(mRows.size() > mHistorySize)
|
||||
{
|
||||
mRowMap.remove(mRows.removeLast().getItem());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Table row item for a history item. The revision increments each time the item changes so that the row cells
|
||||
* update.
|
||||
*/
|
||||
public static class Row<T>
|
||||
{
|
||||
private final T mItem;
|
||||
private final IntegerProperty mRevision = new SimpleIntegerProperty();
|
||||
|
||||
Row(T item)
|
||||
{
|
||||
mItem = item;
|
||||
}
|
||||
|
||||
public T getItem()
|
||||
{
|
||||
return mItem;
|
||||
}
|
||||
|
||||
public IntegerProperty revisionProperty()
|
||||
{
|
||||
return mRevision;
|
||||
}
|
||||
|
||||
void refresh()
|
||||
{
|
||||
mRevision.set(mRevision.get() + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.filter.FilterSet;
|
||||
import io.github.dsheirer.message.IMessage;
|
||||
import io.github.dsheirer.message.MessageHistory;
|
||||
import io.github.dsheirer.message.StuffBitsMessage;
|
||||
import io.github.dsheirer.module.ProcessingChain;
|
||||
import io.github.dsheirer.module.decode.DecoderFactory;
|
||||
import io.github.dsheirer.module.decode.event.MessageItem;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.TableColumn;
|
||||
|
||||
/**
|
||||
* Table of the decoded messages for the selected channel.
|
||||
*/
|
||||
public class MessageActivityView extends HistoryTableView<MessageItem,IMessage> implements Listener<ProcessingChain>
|
||||
{
|
||||
private static final String TABLE_KEY = "now.playing.messages";
|
||||
private static final String SWING_TABLE_KEY = "message.activity.panel";
|
||||
private MessageHistory mCurrentMessageHistory;
|
||||
private Listener<IMessage> mCurrentMessageListener;
|
||||
private FilterSet<IMessage> mMessageFilterSet;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param userPreferences for the column settings
|
||||
*/
|
||||
public MessageActivityView(UserPreferences userPreferences)
|
||||
{
|
||||
super(createColumns(), TABLE_KEY, userPreferences.getSwingPreference(), SWING_TABLE_KEY,
|
||||
"Message Filter Editor", "Message Columns");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean include(MessageItem item)
|
||||
{
|
||||
FilterSet<IMessage> filterSet = mMessageFilterSet;
|
||||
|
||||
if(filterSet != null && item.getMessage() != null)
|
||||
{
|
||||
return filterSet.canProcess(item.getMessage()) && filterSet.passes(item.getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the messages for the processing chain. Call on the JavaFX application thread.
|
||||
* @param processingChain to show, or null to clear the view
|
||||
*/
|
||||
@Override
|
||||
public void receive(ProcessingChain processingChain)
|
||||
{
|
||||
if(mCurrentMessageHistory != null)
|
||||
{
|
||||
mCurrentMessageHistory.removeListener(mCurrentMessageListener);
|
||||
}
|
||||
|
||||
//Unregister from changes made to the filter set
|
||||
if(mMessageFilterSet != null)
|
||||
{
|
||||
mMessageFilterSet.register(null);
|
||||
}
|
||||
|
||||
mCurrentMessageHistory = null;
|
||||
mCurrentMessageListener = null;
|
||||
|
||||
if(processingChain != null)
|
||||
{
|
||||
MessageHistory history = processingChain.getMessageHistory();
|
||||
FilterSet<IMessage> filterSet = DecoderFactory.getMessageFilters(processingChain.getModules());
|
||||
mMessageFilterSet = filterSet;
|
||||
//Filter changes come from the Swing filter editor
|
||||
filterSet.register(() -> Platform.runLater(() -> {
|
||||
if(mMessageFilterSet == filterSet)
|
||||
{
|
||||
refilter();
|
||||
}
|
||||
}));
|
||||
getHistoryControls().setFilterSet(filterSet);
|
||||
|
||||
//A listener for each history so that messages from the previous channel are ignored after a change
|
||||
Listener<IMessage> listener = new Listener<>()
|
||||
{
|
||||
@Override
|
||||
public void receive(IMessage message)
|
||||
{
|
||||
//Don't process tail bits or stuff bits message fragments
|
||||
if(!(message instanceof StuffBitsMessage))
|
||||
{
|
||||
MessageItem item = new MessageItem(message);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if(mCurrentMessageListener == this)
|
||||
{
|
||||
add(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mCurrentMessageHistory = history;
|
||||
mCurrentMessageListener = listener;
|
||||
|
||||
List<MessageItem> items = new ArrayList<>();
|
||||
|
||||
for(IMessage message: history.getItems())
|
||||
{
|
||||
items.add(new MessageItem(message));
|
||||
}
|
||||
|
||||
setItems(items);
|
||||
refilter();
|
||||
history.addListener(listener);
|
||||
getHistoryControls().setControlsEnabled(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
mMessageFilterSet = null;
|
||||
clear();
|
||||
getHistoryControls().setControlsEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TableColumn<Row<MessageItem>,?>> createColumns()
|
||||
{
|
||||
SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
|
||||
List<TableColumn<Row<MessageItem>,?>> columns = new ArrayList<>();
|
||||
columns.add(NowPlayingCells.column("time", "Time", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getTimestamp(timeFormat), null));
|
||||
columns.add(NowPlayingCells.column("protocol", "Protocol", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getProtocol(), null));
|
||||
columns.add(NowPlayingCells.column("timeslot", "Timeslot", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getTimeslot(), null));
|
||||
columns.add(NowPlayingCells.column("message", "Message", null, Pos.CENTER_LEFT,
|
||||
row -> row.getItem().getText(), null));
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.gui.table.TableColumnMonitor;
|
||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.icon.Icon;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import java.util.function.Function;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.util.Callback;
|
||||
|
||||
import javax.swing.UIManager;
|
||||
|
||||
/**
|
||||
* Table column and cell helpers for the Now Playing tables.
|
||||
*/
|
||||
public final class NowPlayingCells
|
||||
{
|
||||
private static final String ALIGNMENT = "now.playing.cell.alignment";
|
||||
|
||||
private NowPlayingCells()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a table column.
|
||||
* @param id that identifies the column for storing the column settings. Don't change it, or users lose the
|
||||
* stored order, visibility and width of the column.
|
||||
* @param text for the column header
|
||||
* @param configurationName for the column in the column configuration editor, or null to use the header text
|
||||
* @param alignment of the cell contents
|
||||
* @param value of the column for the row item. The value is recalculated each time the row revision changes.
|
||||
* @param revision of the row item, or null when the row value doesn't change
|
||||
*/
|
||||
public static <S,T> TableColumn<S,T> column(String id, String text, String configurationName, Pos alignment,
|
||||
Function<S,T> value, Function<S,ObservableValue<?>> revision)
|
||||
{
|
||||
TableColumn<S,T> column = new TableColumn<>(text);
|
||||
column.setId(id);
|
||||
|
||||
if(configurationName != null)
|
||||
{
|
||||
TableColumnMonitor.setName(column, configurationName);
|
||||
}
|
||||
|
||||
column.getProperties().put(ALIGNMENT, alignment);
|
||||
column.setCellFactory(formatted(Object::toString));
|
||||
|
||||
if(revision == null)
|
||||
{
|
||||
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue())));
|
||||
}
|
||||
else
|
||||
{
|
||||
column.setCellValueFactory(param -> Bindings.createObjectBinding(() -> value.apply(param.getValue()),
|
||||
revision.apply(param.getValue())));
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell factory that shows the cell value formatted as text.
|
||||
* @param formatter for non-null values. Can return null for an empty cell.
|
||||
*/
|
||||
public static <S,T> Callback<TableColumn<S,T>,TableCell<S,T>> formatted(Function<T,String> formatter)
|
||||
{
|
||||
return column -> new AlignedCell<>(column)
|
||||
{
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty)
|
||||
{
|
||||
super.updateItem(item, empty);
|
||||
setText(empty || item == null ? null : formatter.apply(item));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell factory for alias values that shows the alias names in the alias color with the alias icon.
|
||||
* @param iconModel for the alias icons
|
||||
*/
|
||||
public static <S> Callback<TableColumn<S,AliasDisplay>,TableCell<S,AliasDisplay>> alias(IconModel iconModel)
|
||||
{
|
||||
return column -> new AlignedCell<>(column)
|
||||
{
|
||||
@Override
|
||||
protected void updateItem(AliasDisplay item, boolean empty)
|
||||
{
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if(empty || item == null)
|
||||
{
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setStyle(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
setText(item.text());
|
||||
|
||||
Icon icon = iconModel.getIcon(item.iconName());
|
||||
setGraphic(icon != null && icon.getFxImage() != null ? new ImageView(icon.getFxImage()) : null);
|
||||
|
||||
java.awt.Color color = new java.awt.Color(item.color());
|
||||
java.awt.Color background = UIManager.getColor("Table.background");
|
||||
java.awt.Color readable = background != null ?
|
||||
ThemeManager.getInstance().getReadableColor(color, background) : color;
|
||||
String fill = toWebColor(readable.getRGB());
|
||||
//An inline style, since the themed table row style otherwise replaces the text fill
|
||||
setStyle("-fx-text-fill: " + fill + ";");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the RGB value as a web color.
|
||||
* @param rgb color value
|
||||
* @return web color, e.g. #FF0000
|
||||
*/
|
||||
public static String toWebColor(int rgb)
|
||||
{
|
||||
return String.format("#%06X", rgb & 0xFFFFFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table cell that aligns its contents with the alignment of its column.
|
||||
*/
|
||||
public static class AlignedCell<S,T> extends TableCell<S,T>
|
||||
{
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param column of the cell
|
||||
*/
|
||||
public AlignedCell(TableColumn<S,T> column)
|
||||
{
|
||||
if(column.getProperties().get(ALIGNMENT) instanceof Pos alignment)
|
||||
{
|
||||
setAlignment(alignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.JFXPanel;
|
||||
import javafx.scene.Scene;
|
||||
|
||||
/**
|
||||
* Swing panel that holds the JavaFX Now Playing view for the main application window. The JavaFX view is created
|
||||
* on the JavaFX application thread after construction.
|
||||
*/
|
||||
public class NowPlayingPanel extends JFXPanel
|
||||
{
|
||||
private NowPlayingView mNowPlayingView;
|
||||
private boolean mDetailTabsVisible;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param playlistManager for the channels
|
||||
* @param iconModel for alias icons
|
||||
* @param userPreferences for display formats and column settings
|
||||
* @param detailTabsVisible to show the channel detail tabs
|
||||
*/
|
||||
public NowPlayingPanel(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
|
||||
boolean detailTabsVisible)
|
||||
{
|
||||
mDetailTabsVisible = detailTabsVisible;
|
||||
Platform.setImplicitExit(false);
|
||||
ThemeManager.getInstance().registerSwing(this);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
NowPlayingView view;
|
||||
|
||||
synchronized(NowPlayingPanel.this)
|
||||
{
|
||||
view = new NowPlayingView(playlistManager, iconModel, userPreferences, mDetailTabsVisible);
|
||||
mNowPlayingView = view;
|
||||
}
|
||||
|
||||
Scene scene = new Scene(view);
|
||||
ThemeManager.getInstance().register(scene);
|
||||
setScene(scene);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the visibility of the channel details tabs. Can be called from any thread.
|
||||
* @param visible true to show or false to hide.
|
||||
*/
|
||||
public synchronized void setDetailTabsVisible(boolean visible)
|
||||
{
|
||||
mDetailTabsVisible = visible;
|
||||
|
||||
if(mNowPlayingView != null)
|
||||
{
|
||||
NowPlayingView view = mNowPlayingView;
|
||||
Platform.runLater(() -> view.setDetailTabsVisible(visible));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.gui.nowplaying;
|
||||
|
||||
import io.github.dsheirer.gui.channel.ChannelSpectrumView;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.scene.control.SplitPane;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TabPane;
|
||||
|
||||
/**
|
||||
* Now Playing view: the table of currently processing channels above the details, events, messages and channel tabs
|
||||
* for the selected channel.
|
||||
*
|
||||
* Use this class on the JavaFX application thread.
|
||||
*/
|
||||
public class NowPlayingView extends SplitPane
|
||||
{
|
||||
private static final double DIVIDER_POSITION = 0.5;
|
||||
private final ChannelMetadataView mChannelMetadataView;
|
||||
private final ChannelDetailView mChannelDetailView;
|
||||
private final DecodeEventView mDecodeEventView;
|
||||
private final MessageActivityView mMessageActivityView;
|
||||
private final ChannelSpectrumView mChannelSpectrumView;
|
||||
private final TabPane mTabPane = new TabPane();
|
||||
private boolean mDetailTabsVisible;
|
||||
|
||||
/**
|
||||
* Constructs an instance. Construct on the JavaFX application thread.
|
||||
* @param playlistManager for the channels
|
||||
* @param iconModel for alias icons
|
||||
* @param userPreferences for display formats and column settings
|
||||
* @param detailTabsVisible to show the detail tabs
|
||||
*/
|
||||
public NowPlayingView(PlaylistManager playlistManager, IconModel iconModel, UserPreferences userPreferences,
|
||||
boolean detailTabsVisible)
|
||||
{
|
||||
setOrientation(Orientation.VERTICAL);
|
||||
|
||||
mChannelMetadataView = new ChannelMetadataView(playlistManager.getChannelProcessingManager(), iconModel, userPreferences);
|
||||
mChannelDetailView = new ChannelDetailView(playlistManager.getChannelProcessingManager());
|
||||
mDecodeEventView = new DecodeEventView(iconModel, userPreferences, playlistManager.getAliasModel());
|
||||
mMessageActivityView = new MessageActivityView(userPreferences);
|
||||
mChannelSpectrumView = new ChannelSpectrumView(playlistManager);
|
||||
|
||||
Tab detailsTab = new Tab("Details", mChannelDetailView);
|
||||
Tab channelTab = new Tab("Channel", mChannelSpectrumView);
|
||||
mTabPane.getTabs().addAll(detailsTab, new Tab("Events", mDecodeEventView),
|
||||
new Tab("Messages", mMessageActivityView), channelTab);
|
||||
mTabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
|
||||
|
||||
//Turn the channel spectrum processing on only while the channel tab is selected
|
||||
mTabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
|
||||
mChannelSpectrumView.setPanelVisible(newTab == channelTab);
|
||||
|
||||
//Show current details each time the user opens the details tab
|
||||
if(newTab == detailsTab)
|
||||
{
|
||||
mChannelDetailView.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
mChannelMetadataView.addProcessingChainSelectionListener(mChannelDetailView);
|
||||
mChannelMetadataView.addProcessingChainSelectionListener(mDecodeEventView);
|
||||
mChannelMetadataView.addProcessingChainSelectionListener(mMessageActivityView);
|
||||
mChannelMetadataView.addProcessingChainSelectionListener(mChannelSpectrumView);
|
||||
|
||||
getItems().add(mChannelMetadataView);
|
||||
setDetailTabsVisible(detailTabsVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the visibility of the channel details tabs.
|
||||
* @param visible true to show or false to hide.
|
||||
*/
|
||||
public void setDetailTabsVisible(boolean visible)
|
||||
{
|
||||
if(visible && !getItems().contains(mTabPane))
|
||||
{
|
||||
getItems().add(mTabPane);
|
||||
setDividerPositions(DIVIDER_POSITION);
|
||||
}
|
||||
else if(!visible)
|
||||
{
|
||||
getItems().remove(mTabPane);
|
||||
}
|
||||
|
||||
mDetailTabsVisible = visible;
|
||||
//The channel tab is hidden with the detail tabs, so stop its spectrum processing
|
||||
mChannelSpectrumView.setPanelVisible(mDetailTabsVisible &&
|
||||
mTabPane.getSelectionModel().getSelectedIndex() == mTabPane.getTabs().size() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares this view for disposal.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
mChannelMetadataView.dispose();
|
||||
mDecodeEventView.dispose();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ package io.github.dsheirer.gui.playlist.channel;
|
||||
|
||||
import io.github.dsheirer.gui.control.AccessibilityUtil;
|
||||
import io.github.dsheirer.gui.control.AccessibleToggleSwitch;
|
||||
import io.github.dsheirer.gui.control.DbPowerMeter;
|
||||
import io.github.dsheirer.gui.power.PowerMeter;
|
||||
import io.github.dsheirer.gui.control.HexFormatter;
|
||||
import io.github.dsheirer.gui.control.IntegerFormatter;
|
||||
import io.github.dsheirer.gui.playlist.eventlog.EventLogConfigurationEditor;
|
||||
@@ -80,8 +80,8 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
|
||||
private TextField mSquelchThresholdField;
|
||||
private ToggleSwitch mSquelchAutoTrackSwitch;
|
||||
private TextFormatter<Integer> mTalkgroupTextFormatter;
|
||||
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER,
|
||||
(int)DbPowerMeter.DEFAULT_MAXIMUM_POWER);
|
||||
private IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)PowerMeter.DEFAULT_MINIMUM_POWER,
|
||||
(int)PowerMeter.DEFAULT_MAXIMUM_POWER);
|
||||
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
||||
private TalkgroupValueChangeListener mTalkgroupValueChangeListener = new TalkgroupValueChangeListener();
|
||||
private IntegerFormatter mDecimalFormatter = new IntegerFormatter(1, 65535);
|
||||
|
||||
@@ -73,6 +73,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
||||
private RecordConfigurationEditor mRecordConfigurationEditor;
|
||||
private ToggleSwitch mIgnoreDataCallsButton;
|
||||
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||
private SegmentedButton mModulationSegmentedButton;
|
||||
private ToggleButton mC4FMToggleButton;
|
||||
@@ -157,6 +158,15 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
GridPane.setConstraints(directionLabel, 5, row);
|
||||
gridPane.getChildren().add(directionLabel);
|
||||
|
||||
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 6, row);
|
||||
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
|
||||
|
||||
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
|
||||
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
|
||||
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
|
||||
GridPane.setConstraints(ignoreEncryptedLabel, 7, row);
|
||||
gridPane.getChildren().add(ignoreEncryptedLabel);
|
||||
|
||||
row++;
|
||||
|
||||
Label modulationHelpLabel = new Label("C4FM: repeaters and non-simulcast trunked systems. LSM: simulcast trunked systems.");
|
||||
@@ -343,6 +353,21 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
return mLSMToggleButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreEncryptedCallsButton()
|
||||
{
|
||||
if(mIgnoreEncryptedCallsButton == null)
|
||||
{
|
||||
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
|
||||
mIgnoreEncryptedCallsButton.setDisable(true);
|
||||
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
|
||||
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
|
||||
mIgnoreEncryptedCallsButton.selectedProperty()
|
||||
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
|
||||
}
|
||||
|
||||
return mIgnoreEncryptedCallsButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreDataCallsButton()
|
||||
{
|
||||
if(mIgnoreDataCallsButton == null)
|
||||
@@ -398,11 +423,13 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
protected void setDecoderConfiguration(DecodeConfiguration config)
|
||||
{
|
||||
getIgnoreDataCallsButton().setDisable(config == null);
|
||||
getIgnoreEncryptedCallsButton().setDisable(config == null);
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(config == null);
|
||||
|
||||
if(config instanceof DecodeConfigP25Phase1 decodeConfig)
|
||||
{
|
||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||
if(decodeConfig.getModulation() == Modulation.C4FM)
|
||||
{
|
||||
@@ -427,6 +454,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
else
|
||||
{
|
||||
getIgnoreDataCallsButton().setSelected(false);
|
||||
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0);
|
||||
getJmbeAGCButton().setDisable(true);
|
||||
getJmbeNoiseGainSlider().setDisable(true);
|
||||
@@ -454,6 +482,7 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
}
|
||||
|
||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||
config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK);
|
||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||
|
||||
@@ -71,6 +71,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
private IntegerTextField mSystemTextField;
|
||||
private IntegerTextField mNacTextField;
|
||||
private ToggleSwitch mIgnoreDataCallsButton;
|
||||
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||
private ToggleSwitch mJmbeAGCButton;
|
||||
private Slider mJmbeToneGainSlider;
|
||||
@@ -142,6 +143,15 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
GridPane.setConstraints(directionLabel, 3, row);
|
||||
gridPane.getChildren().add(directionLabel);
|
||||
|
||||
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 4, row);
|
||||
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
|
||||
|
||||
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
|
||||
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
|
||||
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
|
||||
GridPane.setConstraints(ignoreEncryptedLabel, 5, row);
|
||||
gridPane.getChildren().add(ignoreEncryptedLabel);
|
||||
|
||||
Label wacnLabel = new Label("WACN");
|
||||
wacnLabel.setLabelFor(getWacnTextField());
|
||||
GridPane.setHalignment(wacnLabel, HPos.RIGHT);
|
||||
@@ -300,6 +310,21 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
return mEventLogConfigurationEditor;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreEncryptedCallsButton()
|
||||
{
|
||||
if(mIgnoreEncryptedCallsButton == null)
|
||||
{
|
||||
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
|
||||
mIgnoreEncryptedCallsButton.setDisable(true);
|
||||
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
|
||||
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
|
||||
mIgnoreEncryptedCallsButton.selectedProperty()
|
||||
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
|
||||
}
|
||||
|
||||
return mIgnoreEncryptedCallsButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreDataCallsButton()
|
||||
{
|
||||
if(mIgnoreDataCallsButton == null)
|
||||
@@ -417,6 +442,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
|
||||
getIgnoreDataCallsButton().setDisable(false);
|
||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||
getIgnoreEncryptedCallsButton().setDisable(false);
|
||||
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(false);
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||
getJmbeAGCButton().setSelected(decodeConfig.isAGC());
|
||||
@@ -433,6 +460,8 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
getSystemTextField().setDisable(true);
|
||||
getNacTextField().setDisable(true);
|
||||
getIgnoreDataCallsButton().setDisable(true);
|
||||
getIgnoreEncryptedCallsButton().setDisable(true);
|
||||
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(true);
|
||||
getJmbeAGCButton().setDisable(true);
|
||||
getJmbeNoiseGainSlider().setDisable(true);
|
||||
@@ -465,6 +494,7 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
int nac = getNacTextField().get();
|
||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||
config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue());
|
||||
|
||||
@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@@ -92,8 +91,9 @@ public class AgencyEditor extends VBox
|
||||
|
||||
if(agencies != null && !agencies.isEmpty())
|
||||
{
|
||||
Collections.sort(agencies, new AgencyComparator());
|
||||
//The agencies list belongs to a cached state or county info shared with other callers, so sort the items
|
||||
getAgencyListView().getItems().addAll(agencies);
|
||||
getAgencyListView().getItems().sort(new AgencyComparator());
|
||||
|
||||
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference;
|
||||
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||
import io.github.dsheirer.rrapi.type.Category;
|
||||
import io.github.dsheirer.rrapi.type.Frequency;
|
||||
import io.github.dsheirer.rrapi.type.Mode;
|
||||
@@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane
|
||||
{
|
||||
clear();
|
||||
|
||||
if(categories != null)
|
||||
{
|
||||
Collections.sort(categories, new CategoryComparator());
|
||||
}
|
||||
|
||||
if(categories != null && !categories.isEmpty())
|
||||
{
|
||||
//The categories list belongs to a cached agency or county info shared with other callers, so change a copy
|
||||
List<Category> sorted = new ArrayList<>(categories);
|
||||
sorted.sort(new CategoryComparator());
|
||||
|
||||
// Only allow the combined list of all categories if there aren't that many
|
||||
if (categories.size() < 10) {
|
||||
categories.add(0, ALL_CATEGORIES);
|
||||
if (sorted.size() < 10) {
|
||||
sorted.add(0, ALL_CATEGORIES);
|
||||
}
|
||||
getCategoryComboBox().getItems().addAll(categories);
|
||||
getCategoryComboBox().getSelectionModel().select(categories.get(0));
|
||||
getCategoryComboBox().getItems().addAll(sorted);
|
||||
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane
|
||||
|
||||
Collections.sort(frequencies, new FrequencyComparator());
|
||||
|
||||
//Load the cached modes map here so that the mode column doesn't call the service on the FX
|
||||
//thread. On failure, the mode column shows an error and the frequencies are still listed.
|
||||
try
|
||||
{
|
||||
mRadioReference.getService().getModesMap();
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
mLog.warn("Unable to retrieve radio reference modes - " + rre.getMessage());
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
getFrequencyTableView().getItems().addAll(frequencies);
|
||||
setLoading(false);
|
||||
@@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane
|
||||
catch(Throwable t)
|
||||
{
|
||||
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
|
||||
Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait());
|
||||
Platform.runLater(() -> {
|
||||
setLoading(false);
|
||||
new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
|
||||
|
||||
if(item != null)
|
||||
{
|
||||
mFrequency = (long)(item.getDownlink() * 1E6);
|
||||
mFrequency = item.getDownlinkHz();
|
||||
getAlphaTagTextField().setText(item.getAlphaTag());
|
||||
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
||||
getToneTextField().setText(item.getTone());
|
||||
@@ -413,46 +413,7 @@ public class FrequencyEditor extends VBox
|
||||
*/
|
||||
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
|
||||
{
|
||||
if(modeDecoderType.hasDecoderType())
|
||||
{
|
||||
Channel channel = new Channel();
|
||||
channel.setSystem(system);
|
||||
channel.setSite(site);
|
||||
channel.setName(name);
|
||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||
sourceConfigTuner.setFrequency(frequency);
|
||||
channel.setSourceConfiguration(sourceConfigTuner);
|
||||
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
|
||||
|
||||
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
|
||||
{
|
||||
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
|
||||
}
|
||||
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
|
||||
{
|
||||
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
|
||||
}
|
||||
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
|
||||
{
|
||||
if(modeDecoderType == ModeDecoderType.NXDN48)
|
||||
{
|
||||
nxdn.setTransmissionMode(TransmissionMode.M4800);
|
||||
}
|
||||
else if(modeDecoderType == ModeDecoderType.NXDN96)
|
||||
{
|
||||
nxdn.setTransmissionMode(TransmissionMode.M9600);
|
||||
}
|
||||
}
|
||||
|
||||
channel.setDecodeConfiguration(decodeConfiguration);
|
||||
return channel;
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
|
||||
}
|
||||
|
||||
return null;
|
||||
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name);
|
||||
}
|
||||
|
||||
private TextField getSystemTextField()
|
||||
|
||||
@@ -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.type.AuthorizationInformation;
|
||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.event.ActionEvent;
|
||||
@@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.text.ParseException;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
@@ -60,6 +60,8 @@ import java.util.Date;
|
||||
public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
|
||||
private static final String TEST_CONNECTION = "Test Connection";
|
||||
private static final String TESTING_CONNECTION = "Testing...";
|
||||
private UserPreferences mUserPreferences;
|
||||
private TextField mUserNameText;
|
||||
private PasswordField mPasswordField;
|
||||
@@ -278,7 +280,7 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||
{
|
||||
if(mTestConnectionButton == null)
|
||||
{
|
||||
mTestConnectionButton = new Button("Test Connection");
|
||||
mTestConnectionButton = new Button(TEST_CONNECTION);
|
||||
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
|
||||
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
|
||||
{
|
||||
@@ -316,98 +318,26 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||
return;
|
||||
}
|
||||
|
||||
Platform.runLater(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
getTestConnectionButton().setText(TESTING_CONNECTION);
|
||||
|
||||
//The test calls the web service, which can take up to the connect and request timeouts, so it runs
|
||||
//off the FX thread and the result is shown on the FX thread
|
||||
ThreadPool.CACHED.execute(() -> {
|
||||
RadioReference.LoginStatus loginStatus = null;
|
||||
Exception error = null;
|
||||
|
||||
try
|
||||
{
|
||||
boolean success = false;
|
||||
RadioReference.LoginStatus loginStatus;
|
||||
|
||||
try
|
||||
{
|
||||
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
||||
|
||||
if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
|
||||
{
|
||||
getTestPassIcon().setVisible(true);
|
||||
getTestFailIcon().setVisible(false);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
//Success is only shown as an icon, so report it in the button's accessible name
|
||||
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
||||
}
|
||||
else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
||||
{
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(false);
|
||||
getTestExpiredIcon().setVisible(true);
|
||||
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
|
||||
alert.setHeaderText("Expired Premium Account");
|
||||
alert.setTitle("Radio Reference Account issue");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
}
|
||||
else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
||||
{
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(true);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Please verify username and password", ButtonType.OK);
|
||||
alert.setHeaderText("Login Failed");
|
||||
alert.setTitle("Test Failed");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Only way to get here is via an exception
|
||||
}
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
|
||||
// Set an error state
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(true);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
|
||||
if(rre.getCause() instanceof ConnectException)
|
||||
{
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Please check network or radio reference availability", ButtonType.OK);
|
||||
alert.setHeaderText("No Network Connection");
|
||||
alert.setTitle("Test Failed");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
mLog.error("No network connection to radioreference.com");
|
||||
}
|
||||
else if(rre.hasFault())
|
||||
{
|
||||
Fault fault = rre.getFault();
|
||||
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, fault.getFaultString(), ButtonType.OK);
|
||||
alert.setHeaderText(fault.getFaultCode());
|
||||
alert.setTitle("Test Failed");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
|
||||
}
|
||||
else
|
||||
{
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR, "Error: " + rre.getMessage(), ButtonType.OK);
|
||||
alert.setHeaderText("Unknown Error");
|
||||
alert.setTitle("Test Failed");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
mLog.error("Error testing connection to radioreference.com", rre);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getTestConnectionButton().setDisable(false);
|
||||
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
error = e;
|
||||
}
|
||||
|
||||
final RadioReference.LoginStatus status = loginStatus;
|
||||
final Exception exception = error;
|
||||
Platform.runLater(() -> showTestConnectionResult(status, exception));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -416,6 +346,111 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||
return mTestConnectionButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the result of a connection test. Must be called on the FX thread.
|
||||
* @param loginStatus from the test, or null if the test failed with an error
|
||||
* @param error from the test, or null
|
||||
*/
|
||||
private void showTestConnectionResult(RadioReference.LoginStatus loginStatus, Exception error)
|
||||
{
|
||||
getTestConnectionButton().setText(TEST_CONNECTION);
|
||||
getTestConnectionButton().setDisable(false);
|
||||
|
||||
//Results that arrive after the dialog closes are only logged
|
||||
boolean showing = isShowing();
|
||||
|
||||
if(error != null)
|
||||
{
|
||||
// Set an error state
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(true);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
|
||||
RadioReferenceException.ErrorType errorType = error instanceof RadioReferenceException rre ?
|
||||
rre.getErrorType() : RadioReferenceException.ErrorType.UNKNOWN;
|
||||
|
||||
//The service has a 10 second connect timeout, so an unreachable service is reported as TIMEOUT as well
|
||||
//as NETWORK
|
||||
if(errorType == RadioReferenceException.ErrorType.NETWORK ||
|
||||
errorType == RadioReferenceException.ErrorType.TIMEOUT)
|
||||
{
|
||||
mLog.error("No network connection to radioreference.com - " + error.getMessage());
|
||||
|
||||
if(showing)
|
||||
{
|
||||
showTestFailedAlert(Alert.AlertType.ERROR, "No Network Connection",
|
||||
"Please check network or radio reference availability");
|
||||
}
|
||||
}
|
||||
else if(error instanceof RadioReferenceException rre && rre.hasFault())
|
||||
{
|
||||
Fault fault = rre.getFault();
|
||||
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
|
||||
|
||||
if(showing)
|
||||
{
|
||||
showTestFailedAlert(Alert.AlertType.ERROR, fault.getFaultCode(), fault.getFaultString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.error("Error testing connection to radioreference.com", error);
|
||||
|
||||
if(showing)
|
||||
{
|
||||
showTestFailedAlert(Alert.AlertType.ERROR, "Unknown Error", "Error: " + error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
|
||||
{
|
||||
getTestPassIcon().setVisible(true);
|
||||
getTestFailIcon().setVisible(false);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
//Success is only shown as an icon, so report it in the button's accessible name
|
||||
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
|
||||
}
|
||||
else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
|
||||
{
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(false);
|
||||
getTestExpiredIcon().setVisible(true);
|
||||
|
||||
if(showing)
|
||||
{
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
|
||||
alert.setHeaderText("Expired Premium Account");
|
||||
alert.setTitle("Radio Reference Account issue");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
}
|
||||
}
|
||||
else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
|
||||
{
|
||||
getTestPassIcon().setVisible(false);
|
||||
getTestFailIcon().setVisible(true);
|
||||
getTestExpiredIcon().setVisible(false);
|
||||
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
|
||||
|
||||
if(showing)
|
||||
{
|
||||
showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert for a failed connection test. Must be called on the FX thread.
|
||||
*/
|
||||
private void showTestFailedAlert(Alert.AlertType alertType, String header, String content)
|
||||
{
|
||||
Alert alert = new Alert(alertType, content, ButtonType.OK);
|
||||
alert.setHeaderText(header);
|
||||
alert.setTitle("Test Failed");
|
||||
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
private IconNode getTestPassIcon()
|
||||
{
|
||||
if(mTestPassIcon == null)
|
||||
|
||||
@@ -70,8 +70,28 @@ public enum ModeDecoderType
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup the entry that matches the mode
|
||||
*
|
||||
* Lookup the matching entry for a radio reference mode name, e.g. FMN
|
||||
* @param name of the mode
|
||||
* @return matching entry or UNKNOWN
|
||||
*/
|
||||
public static ModeDecoderType fromName(String name)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(ModeDecoderType modeDecoderType : ModeDecoderType.values())
|
||||
{
|
||||
if(modeDecoderType.getValue().contentEquals(name.trim().toLowerCase()))
|
||||
{
|
||||
return modeDecoderType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup the matching entry for the radio reference mode
|
||||
* @param mode to match
|
||||
* @return matching entry or UNKNOWN
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
import io.github.dsheirer.module.decode.DecoderFactory;
|
||||
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
|
||||
import io.github.dsheirer.module.decode.dmr.DecodeConfigDMR;
|
||||
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
|
||||
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
|
||||
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
|
||||
import io.github.dsheirer.module.decode.p25.phase2.enumeration.ScrambleParameters;
|
||||
import io.github.dsheirer.rrapi.type.RadioNetwork;
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
|
||||
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
|
||||
@@ -382,7 +392,7 @@ public class RadioReferenceDecoder
|
||||
|
||||
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
||||
timeslotFrequency.setNumber(lcn);
|
||||
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
|
||||
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
|
||||
frequencies.add(timeslotFrequency);
|
||||
}
|
||||
|
||||
@@ -432,7 +442,7 @@ public class RadioReferenceDecoder
|
||||
}
|
||||
}
|
||||
|
||||
frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0));
|
||||
frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0));
|
||||
}
|
||||
|
||||
return frequencies;
|
||||
@@ -577,4 +587,126 @@ public class RadioReferenceDecoder
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel decode configuration for the specified site.
|
||||
* @param decoderType to create
|
||||
* @param site information and frequencies
|
||||
* @param systemInformation with frequency mapping
|
||||
* @return decode configuration
|
||||
*/
|
||||
public DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
||||
{
|
||||
if(decoderType == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch(decoderType)
|
||||
{
|
||||
case DMR:
|
||||
DecodeConfigDMR dmr = new DecodeConfigDMR();
|
||||
List<TimeslotFrequency> timeslotFrequencies = getTimeslotFrequencies(systemInformation, site);
|
||||
dmr.setTimeslotMap(timeslotFrequencies);
|
||||
return dmr;
|
||||
case NXDN:
|
||||
Flavor flavor = getFlavor(systemInformation);
|
||||
|
||||
TransmissionMode mode = TransmissionMode.M4800;
|
||||
|
||||
/**
|
||||
* 9600 bps Flavors
|
||||
* ----------------------------
|
||||
* 29 - NEXEDGE 9600
|
||||
* 45 - Conventional Networked
|
||||
*
|
||||
* 4800 bps Flavors
|
||||
* ---------------------------
|
||||
* 11 - Narrowband Networked
|
||||
* 42 - NEXEDGE 4800
|
||||
* 44 - Icom IDAS Type C
|
||||
*
|
||||
* Type-D (also 4800 bps) Flavors
|
||||
* ---------------------------
|
||||
* 36 - Icom IDAS Type D
|
||||
* 47 - Kenwood Type D
|
||||
*/
|
||||
mode = switch(flavor.getName())
|
||||
{
|
||||
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
|
||||
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
|
||||
default -> TransmissionMode.M4800;
|
||||
};
|
||||
|
||||
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
|
||||
List<ChannelFrequency> channelMap = getChannelMap(systemInformation, site);
|
||||
nxdn.setChannelMap(channelMap);
|
||||
return nxdn;
|
||||
case P25_PHASE1:
|
||||
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
|
||||
|
||||
if(isLSM(site))
|
||||
{
|
||||
if(p1config instanceof DecodeConfigP25Phase1)
|
||||
{
|
||||
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
|
||||
}
|
||||
}
|
||||
return p1config;
|
||||
case P25_PHASE2:
|
||||
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
|
||||
|
||||
int nac = 0;
|
||||
|
||||
if(site != null && site.getNac() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
nac = Integer.parseInt(site.getNac(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
int wacn = 0;
|
||||
int system = 0;
|
||||
|
||||
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
|
||||
!systemInformation.getRadioNetworks().isEmpty())
|
||||
{
|
||||
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
|
||||
|
||||
if(radioNetwork.getWacn() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if(radioNetwork.getSystemId() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||
return config;
|
||||
default:
|
||||
return DecoderFactory.getDecodeConfiguration(decoderType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
package io.github.dsheirer.gui.playlist.radioreference;
|
||||
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||
import io.github.dsheirer.rrapi.type.Agency;
|
||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||
@@ -31,6 +35,7 @@ import io.github.dsheirer.rrapi.type.CountyInfo;
|
||||
import io.github.dsheirer.rrapi.type.State;
|
||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||
import io.github.dsheirer.service.radioreference.RadioReference;
|
||||
import io.github.dsheirer.service.radioreference.UserLocation;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
@@ -42,12 +47,12 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TabPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.util.StringConverter;
|
||||
@@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
|
||||
@@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
private PlaylistManager mPlaylistManager;
|
||||
private VBox mTopBox;
|
||||
private HBox mCredentialsBox;
|
||||
private HBox mLocationBox;
|
||||
private Label mLocationText;
|
||||
private final AtomicLong mLocationRequest = new AtomicLong();
|
||||
private volatile String mLocationKey;
|
||||
private Label mRRStatusText;
|
||||
private TextField mUserNameText;
|
||||
private TextField mAccountExpiresText;
|
||||
@@ -113,20 +123,41 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
{
|
||||
refreshCountries();
|
||||
}
|
||||
|
||||
//The preferred county fallback location needs a premium login
|
||||
refreshLocation();
|
||||
});
|
||||
|
||||
refreshLocation();
|
||||
|
||||
//Updates the location when it's changed elsewhere, e.g. from the map
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the location text when the location or the preferred county changes
|
||||
* @param preferenceType that was updated
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
{
|
||||
if(preferenceType == PreferenceType.RADIO_REFERENCE)
|
||||
{
|
||||
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||
String key = preference.getLocation() + "/" + preference.getPreferredCountyId();
|
||||
|
||||
if(!key.equals(mLocationKey))
|
||||
{
|
||||
mLocationKey = key;
|
||||
Platform.runLater(this::refreshLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void login()
|
||||
{
|
||||
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
|
||||
{
|
||||
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
|
||||
|
||||
if(credentials != null)
|
||||
{
|
||||
ThreadPool.CACHED.execute(() -> accept(credentials));
|
||||
}
|
||||
}
|
||||
//Logs in on a background thread, unless the spectral display has already logged in
|
||||
mRadioReference.loginWithStoredCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
mTopBox = new VBox();
|
||||
mTopBox.setSpacing(5);
|
||||
mTopBox.setPadding(new Insets(10,10,10,10));
|
||||
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
|
||||
mTopBox.getChildren().addAll(getCredentialsBox(), getLocationBox(), getComboBoxPane());
|
||||
}
|
||||
|
||||
return mTopBox;
|
||||
@@ -416,13 +447,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
mCredentialsBox = new HBox();
|
||||
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
mCredentialsBox.setSpacing(5.0);
|
||||
Region leftFiller = new Region();
|
||||
HBox.setHgrow(leftFiller, Priority.ALWAYS);
|
||||
mCredentialsBox.getChildren().add(leftFiller);
|
||||
mCredentialsBox.getChildren().add(getRRPremiumReqText());
|
||||
Region rightFiller = new Region();
|
||||
HBox.setHgrow(rightFiller, Priority.ALWAYS);
|
||||
mCredentialsBox.getChildren().add(rightFiller);
|
||||
mCredentialsBox.getChildren().add(new Label("User Name:"));
|
||||
mCredentialsBox.getChildren().add(getUserNameText());
|
||||
mCredentialsBox.getChildren().add(new Label("Expires:"));
|
||||
@@ -446,8 +471,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
Bindings.and(
|
||||
mRadioReference.availableProperty(),
|
||||
mRadioReference.premiumAccountProperty().not()));
|
||||
mRRStatusText.setTextFill(Color.ORANGERED);
|
||||
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
|
||||
//Inline style, because the theme stylesheets override a label's text fill
|
||||
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
|
||||
}
|
||||
|
||||
return mRRStatusText;
|
||||
@@ -725,6 +750,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
if(selected != null)
|
||||
{
|
||||
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
||||
refreshLocation();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -732,6 +758,111 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
return mCountyComboBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row that shows the user's location and a button to change it
|
||||
*/
|
||||
private HBox getLocationBox()
|
||||
{
|
||||
if(mLocationBox == null)
|
||||
{
|
||||
mLocationBox = new HBox();
|
||||
mLocationBox.setAlignment(Pos.CENTER_LEFT);
|
||||
mLocationBox.setSpacing(5.0);
|
||||
|
||||
Button setLocationButton = new Button("My Location:");
|
||||
setLocationButton.setOnAction(event -> {
|
||||
LocationDialog dialog = new LocationDialog(mRadioReference,
|
||||
mUserPreferences.getRadioReferencePreference().getLocation(), getScene().getWindow());
|
||||
//The map follows the location through the preference update
|
||||
dialog.showAndWait().ifPresent(result -> {
|
||||
mUserPreferences.getRadioReferencePreference().setLocation(result.location());
|
||||
refreshLocation();
|
||||
});
|
||||
});
|
||||
|
||||
mLocationBox.getChildren().addAll(setLocationButton, getLocationText());
|
||||
}
|
||||
|
||||
return mLocationBox;
|
||||
}
|
||||
|
||||
private Label getLocationText()
|
||||
{
|
||||
if(mLocationText == null)
|
||||
{
|
||||
mLocationText = new Label();
|
||||
mLocationText.setTooltip(new Tooltip("Your location is shown on the map and used to find nearby Radio " +
|
||||
"Reference frequencies. When it isn't set, the center of the selected county is used."));
|
||||
}
|
||||
|
||||
return mLocationText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the location text: the user's location, or else the preferred county fallback, which is looked up in
|
||||
* the background.
|
||||
*/
|
||||
private void refreshLocation()
|
||||
{
|
||||
long request = mLocationRequest.incrementAndGet();
|
||||
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||
|
||||
if(location != null)
|
||||
{
|
||||
getLocationText().setText(location.describe());
|
||||
return;
|
||||
}
|
||||
|
||||
String notRanked = "Not set";
|
||||
|
||||
if(mUserPreferences.getRadioReferencePreference().getPreferredCountyId() <= 0 ||
|
||||
!mRadioReference.premiumAccountProperty().get())
|
||||
{
|
||||
getLocationText().setText(notRanked);
|
||||
return;
|
||||
}
|
||||
|
||||
getLocationText().setText("Not set");
|
||||
|
||||
ThreadPool.CACHED.execute(() -> {
|
||||
String text;
|
||||
|
||||
try
|
||||
{
|
||||
UserLocation userLocation = mRadioReference.getUserLocation();
|
||||
|
||||
if(userLocation == null)
|
||||
{
|
||||
text = notRanked;
|
||||
}
|
||||
else if(userLocation.source() == UserLocation.Source.PREFERRED_COUNTY)
|
||||
{
|
||||
text = "Not set - using the center of " + userLocation.description();
|
||||
}
|
||||
else
|
||||
{
|
||||
//Set in the meantime - a newer refresh shows it
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
mLog.warn("Unable to look up the preferred county location - " + rre.getMessage());
|
||||
text = "Not set - unable to look up the selected county";
|
||||
}
|
||||
|
||||
final String locationText = text;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
//A newer refresh supersedes this one
|
||||
if(mLocationRequest.get() == request)
|
||||
{
|
||||
getLocationText().setText(locationText);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Consumer interface method for receiving login credentials
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
if(decoderType == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch(decoderType)
|
||||
{
|
||||
case DMR:
|
||||
DecodeConfigDMR dmr = new DecodeConfigDMR();
|
||||
List<TimeslotFrequency> timeslotFrequencies = mRadioReferenceDecoder
|
||||
.getTimeslotFrequencies(systemInformation, site);
|
||||
dmr.setTimeslotMap(timeslotFrequencies);
|
||||
return dmr;
|
||||
case NXDN:
|
||||
Flavor flavor = mRadioReferenceDecoder.getFlavor(systemInformation);
|
||||
|
||||
TransmissionMode mode = TransmissionMode.M4800;
|
||||
|
||||
/**
|
||||
* 9600 bps Flavors
|
||||
* ----------------------------
|
||||
* 29 - NEXEDGE 9600
|
||||
* 45 - Conventional Networked
|
||||
*
|
||||
* 4800 bps Flavors
|
||||
* ---------------------------
|
||||
* 11 - Narrowband Networked
|
||||
* 42 - NEXEDGE 4800
|
||||
* 44 - Icom IDAS Type C
|
||||
*
|
||||
* Type-D (also 4800 bps) Flavors
|
||||
* ---------------------------
|
||||
* 36 - Icom IDAS Type D
|
||||
* 47 - Kenwood Type D
|
||||
*/
|
||||
mode = switch(flavor.getName())
|
||||
{
|
||||
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
|
||||
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
|
||||
default -> TransmissionMode.M4800;
|
||||
};
|
||||
|
||||
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
|
||||
List<ChannelFrequency> channelMap = mRadioReferenceDecoder.getChannelMap(systemInformation, site);
|
||||
nxdn.setChannelMap(channelMap);
|
||||
return nxdn;
|
||||
case P25_PHASE1:
|
||||
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
|
||||
|
||||
if(mRadioReferenceDecoder.isLSM(site))
|
||||
{
|
||||
if(p1config instanceof DecodeConfigP25Phase1)
|
||||
{
|
||||
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
|
||||
}
|
||||
}
|
||||
return p1config;
|
||||
case P25_PHASE2:
|
||||
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
|
||||
|
||||
int nac = 0;
|
||||
|
||||
if(site != null && site.getNac() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
nac = Integer.parseInt(site.getNac(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
int wacn = 0;
|
||||
int system = 0;
|
||||
|
||||
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
|
||||
!systemInformation.getRadioNetworks().isEmpty())
|
||||
{
|
||||
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
|
||||
|
||||
if(radioNetwork.getWacn() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if(radioNetwork.getSystemId() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||
return config;
|
||||
default:
|
||||
return DecoderFactory.getDecodeConfiguration(decoderType);
|
||||
}
|
||||
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -621,7 +510,7 @@ public class SiteEditor extends GridPane
|
||||
if(controls.size() == 1)
|
||||
{
|
||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
|
||||
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
|
||||
channel.setSourceConfiguration(sourceConfigTuner);
|
||||
}
|
||||
else
|
||||
@@ -630,7 +519,7 @@ public class SiteEditor extends GridPane
|
||||
|
||||
for(SiteFrequency control: controls)
|
||||
{
|
||||
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
|
||||
sourceConfig.addFrequency(control.getFrequencyHz());
|
||||
}
|
||||
|
||||
channel.setSourceConfiguration(sourceConfig);
|
||||
@@ -856,7 +745,7 @@ public class SiteEditor extends GridPane
|
||||
|
||||
private static long getFrequency(SiteFrequency siteFrequency)
|
||||
{
|
||||
return (long)(siteFrequency.getFrequency() * 1E6);
|
||||
return siteFrequency.getFrequencyHz();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,8 +139,9 @@ public class SystemEditor extends VBox
|
||||
|
||||
if(systems != null && !systems.isEmpty())
|
||||
{
|
||||
Collections.sort(systems, new SystemComparator());
|
||||
//The systems list belongs to a cached state or county info shared with other callers, so sort the items
|
||||
getSystemComboBox().getItems().addAll(systems);
|
||||
getSystemComboBox().getItems().sort(new SystemComparator());
|
||||
|
||||
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
||||
|
||||
@@ -302,7 +303,16 @@ public class SystemEditor extends VBox
|
||||
//have a county ID of 99,999 as observed from API (undocumented).
|
||||
if(countyId > 0 && countyId != 99999)
|
||||
{
|
||||
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
||||
//The county name is only for display, so a failed lookup shouldn't fail the system load
|
||||
try
|
||||
{
|
||||
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
mLog.warn("Unable to retrieve county [" + countyId + "] for site [" +
|
||||
site.getSiteNumber() + "] of system [" + system.getSystemId() + "] - " + rre.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
enrichedSites.add(new EnrichedSite(site, countyInfo));
|
||||
|
||||
@@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane
|
||||
|
||||
if(talkgroups != null && !talkgroups.isEmpty())
|
||||
{
|
||||
Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue));
|
||||
//The talkgroup list is shared by the caching service and is read-only, so sort a copy
|
||||
List<Talkgroup> sorted = new ArrayList<>(talkgroups);
|
||||
sorted.sort(Comparator.comparingInt(Talkgroup::getDecimalValue));
|
||||
|
||||
for(Talkgroup talkgroup: talkgroups)
|
||||
for(Talkgroup talkgroup: sorted)
|
||||
{
|
||||
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
|
||||
}
|
||||
|
||||
@@ -119,6 +119,22 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the server URL field. Subclasses can override this for another product that uses the same API.
|
||||
*/
|
||||
protected String getHostLabel()
|
||||
{
|
||||
return "RdioScanner URL";
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the credential field.
|
||||
*/
|
||||
protected String getApiKeyLabel()
|
||||
{
|
||||
return "API Key";
|
||||
}
|
||||
|
||||
protected GridPane getEditorPane()
|
||||
{
|
||||
if(mEditorPane == null)
|
||||
@@ -157,7 +173,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
GridPane.setConstraints(getNameTextField(), 1, row);
|
||||
mEditorPane.getChildren().add(getNameTextField());
|
||||
|
||||
Label apiKeyLabel = new Label("API Key");
|
||||
Label apiKeyLabel = new Label(getApiKeyLabel());
|
||||
apiKeyLabel.setLabelFor(getApiKeyTextField());
|
||||
GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
|
||||
GridPane.setConstraints(apiKeyLabel, 0, ++row);
|
||||
@@ -175,7 +191,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
GridPane.setConstraints(getSystemIdTextField(), 1, row);
|
||||
mEditorPane.getChildren().add(getSystemIdTextField());
|
||||
|
||||
Label hostLabel = new Label("RdioScanner URL");
|
||||
Label hostLabel = new Label(getHostLabel());
|
||||
hostLabel.setLabelFor(getHostTextField());
|
||||
GridPane.setHalignment(hostLabel, HPos.RIGHT);
|
||||
GridPane.setConstraints(hostLabel, 0, ++row);
|
||||
|
||||
@@ -41,6 +41,8 @@ public class StreamEditorFactory
|
||||
return new BroadcastifyStreamEditor(playlistManager);
|
||||
case RDIOSCANNER_CALL:
|
||||
return new RdioScannerEditor(playlistManager);
|
||||
case THINLINE_RADIO:
|
||||
return new ThinlineRadioEditor(playlistManager);
|
||||
case OPENMHZ:
|
||||
return new OpenMHzEditor(playlistManager);
|
||||
case BROADCASTIFY_CALL:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user