Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac45ed0efb | ||
|
|
96bbb022e8 | ||
|
|
b6753a4fa6 | ||
|
|
0889a3bd88 | ||
|
|
10c415fabd | ||
|
|
984a139a81 | ||
|
|
4cb03111bc | ||
|
|
23f47d9122 | ||
|
|
5a45aab2b1 | ||
|
|
9367878702 | ||
|
|
8fbc639a82 | ||
|
|
fa73328936 | ||
|
|
50a644f417 | ||
|
|
7a2bbb8701 | ||
|
|
018a3afd2b | ||
|
|
3612e662e7 | ||
|
|
79215e7623 | ||
|
|
8f646d76f9 | ||
|
|
ea125d7db4 | ||
|
|
fdb44af9ff | ||
|
|
f6db55b35c | ||
|
|
1b8f8c46f6 | ||
|
|
9798107d37 | ||
|
|
e3d7238721 | ||
|
|
40ba3fecdf | ||
|
|
57d6f9d7ed | ||
|
|
a654735337 | ||
|
|
a42a5f1f0d | ||
|
|
c547a126ee | ||
|
|
16c746f552 | ||
|
|
8f56ea05ec | ||
|
|
b749733289 | ||
|
|
b4cfb16159 | ||
|
|
7319cf8f5b | ||
|
|
baf2a7022d | ||
|
|
b95a86c97f | ||
|
|
ed1fe66892 | ||
|
|
1e24673632 | ||
|
|
ed0f5678b3 | ||
|
|
aac1fa0da5 | ||
|
|
c7bb253981 | ||
|
|
af96fe1cf0 | ||
|
|
27d41eb2ee | ||
|
|
2bac6655f3 | ||
|
|
2da7127fd3 | ||
|
|
fed9fe188e | ||
|
|
19ca5205fb | ||
|
|
a7a6d70d40 | ||
|
|
d5230b3bc6 | ||
|
|
7d7abeb31e | ||
|
|
828f720955 | ||
|
|
5f1f90067f | ||
|
|
f4e188261d | ||
|
|
e1233dceaa | ||
|
|
09e728fa1b | ||
|
|
c1895506e0 | ||
|
|
066fafbb81 | ||
|
|
aedb3fee19 | ||
|
|
77314e824e | ||
|
|
74902cddce | ||
|
|
8324903104 | ||
|
|
a757474b06 | ||
|
|
9cfa238e5c | ||
|
|
29ca4c29bb | ||
|
|
82c8112575 | ||
|
|
20e5b2f617 | ||
|
|
aa10d4170c | ||
|
|
aac3aee4bb | ||
|
|
d749c5ab48 | ||
|
|
082e0c5bff | ||
|
|
e69d6dee4a | ||
|
|
cf93ca9f71 | ||
|
|
7cadded6ca | ||
|
|
6fc2f560b7 | ||
|
|
8adf861ed7 | ||
|
|
f4f6ec7db5 | ||
|
|
5d89190348 | ||
|
|
f9fd7a2cfa | ||
|
|
21f14848da | ||
|
|
d1668f1c27 | ||
|
|
d6b61eaa4b | ||
|
|
26c714fc24 | ||
|
|
2e1d8b9c00 | ||
|
|
7cdc2952dc | ||
|
|
f42d1f6c57 | ||
|
|
3e25e1aa2f | ||
|
|
e0405b541f | ||
|
|
c374058e1d | ||
|
|
ac5cbbc49a | ||
|
|
bd51044690 | ||
|
|
cf49dcfa01 | ||
|
|
1982c2d3dc | ||
|
|
5f297f9233 | ||
|
|
37300fb2a6 | ||
|
|
fc8096bdd5 | ||
|
|
d93e3b4028 | ||
|
|
9b0d543f12 | ||
|
|
57762613a8 | ||
|
|
13ef70810c | ||
|
|
6a62f951ad | ||
|
|
0d3e02c596 | ||
|
|
7c22bf6ac1 | ||
|
|
b8113fc757 | ||
|
|
86ab1b26b7 | ||
|
|
de6c964136 | ||
|
|
e477851adf | ||
|
|
d2d4a6fe86 | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
8b5960282f | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 | ||
|
|
4b835bac5c | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
284183836e | ||
|
|
de85aa1e8b | ||
|
|
fb014a1183 | ||
|
|
78049a29d9 | ||
|
|
8fbfcb3712 | ||
|
|
3818c58eff | ||
|
|
4c62b2dfb2 | ||
|
|
d8e1ee3266 | ||
|
|
90bb584813 | ||
|
|
f789a15338 | ||
|
|
6221516893 | ||
|
|
c9cab8457d | ||
|
|
19926c58ac | ||
|
|
955871c27b | ||
|
|
bfcef27b90 | ||
|
|
449d1a9eec | ||
|
|
fe97339ece | ||
|
|
4d5a413fa8 | ||
|
|
25078b3121 | ||
|
|
96a4d15424 | ||
|
|
bf4cd5c07c | ||
|
|
84d11f289f | ||
|
|
f4a22d9779 | ||
|
|
0e767f4250 | ||
|
|
2c26272e0b | ||
|
|
fc1bec1915 | ||
|
|
7dd256f65d | ||
|
|
4d7952d293 | ||
|
|
8a2b0a9aa3 | ||
|
|
c9d87be289 | ||
|
|
012ea1eeb6 | ||
|
|
920af7bde2 | ||
|
|
88a8e795d1 | ||
|
|
44ae5cef5d | ||
|
|
518d9a4d22 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
72f507163a | ||
|
|
f7a3dbaa2d | ||
|
|
c525f3a11f | ||
|
|
2777607ab5 | ||
|
|
a222cbb839 | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
418a05e343 | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 | ||
|
|
38cf7a3569 | ||
|
|
760a26b007 | ||
|
|
47f3e424ff | ||
|
|
f731775346 | ||
|
|
3c5486cc5c | ||
|
|
544515feba | ||
|
|
9a54da0808 | ||
|
|
8bc1f16e2c | ||
|
|
5dc7ce6a22 | ||
|
|
ff408747cd | ||
|
|
6636dd8e98 | ||
|
|
15ab82f1c8 | ||
|
|
0ea9c0ede1 | ||
|
|
c496d90d5a | ||
|
|
c101870b71 | ||
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
a7b41c95c7 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead | ||
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
2298d8eea4 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -39,19 +39,22 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
VERSION=${TAG_NAME#v}
|
||||
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks keystore.jks \
|
||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||
--out app/build/outputs/apk/release/look4sat.apk \
|
||||
--out "$SIGNED_APK" \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||
+6
-2
@@ -39,12 +39,15 @@ captures/
|
||||
.idea/
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following line if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
*.jks
|
||||
*.keystore
|
||||
/*.properties
|
||||
/keystore.properties
|
||||
/app/keystore.jks
|
||||
|
||||
# Hermes agent workspace (plans, local notes)
|
||||
.hermes/
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
@@ -67,3 +70,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
.hermes/
|
||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
||||
## Project Overview
|
||||
|
||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||
data download.
|
||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||
no tracking, no network required after initial data download.
|
||||
|
||||
## Architecture
|
||||
## Architecture & Design
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
@@ -34,9 +33,11 @@ data download.
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
**Feature isolation:**
|
||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||
|
||||
## Build & Run
|
||||
## Build & Platform
|
||||
|
||||
```shell
|
||||
# Debug build
|
||||
@@ -50,54 +51,55 @@ data download.
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
|
||||
## Key Libraries
|
||||
## Tech Stack
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
**TLE vs. OMM/CSV format:**
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
|
||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
|
||||
**Current implementation:**
|
||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||
- TLE data is downloaded from configured sources and stored in Room database
|
||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||
1. Does this need to be built now? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||
3. Does Kotlin/Java stdlib already solve it?
|
||||
4. Does the Android/platform API already solve it?
|
||||
5. Does an already-installed dependency solve it?
|
||||
6. Can this be simpler (including one-liner simple)?
|
||||
7. Only then: write the minimum code that works.
|
||||
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||
- When two options are similar in size, choose the edge-case-correct one.
|
||||
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||
|
||||
**Migration path:**
|
||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||
transition to OMM/CSV automatically.
|
||||
## Bug-Fix Policy
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||
- Fix root cause, not just the reported symptom.
|
||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||
- The smallest correct diff wins only after behavior is understood.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -105,12 +107,13 @@ transition to OMM/CSV automatically.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
|
||||
`core:presentation/MainTheme.kt`, so the data layer currently decides how the UI looks. Known debt, left as
|
||||
upstream shipped it: the fix is a status enum in `core:domain` with the colour mapping in `core:presentation`.
|
||||
Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,24 @@
|
||||
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
|
||||
|
||||
This repository combines two separately-licensed components:
|
||||
|
||||
1. Look4Sat application code (all modules except the DeepCW model)
|
||||
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
|
||||
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
|
||||
|
||||
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
|
||||
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
|
||||
— Licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only)
|
||||
|
||||
Because this combined work incorporates an AGPL-3.0 component, it is
|
||||
distributed under the GNU Affero General Public License v3.0. GPL-3.0
|
||||
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
|
||||
combined work as a whole.
|
||||
|
||||
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
|
||||
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
|
||||
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -34,12 +34,27 @@ It is now and always will be completely ad-free and open-source.
|
||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||
|
||||
## License
|
||||
|
||||
The Look4Sat application code is licensed under the GNU General Public License v3.0.
|
||||
|
||||
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
|
||||
neural decoding model, licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only). Because the combined work incorporates an AGPL-3.0 component, the
|
||||
combined work is distributed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
|
||||
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
|
||||
Model provenance and attribution are documented in
|
||||
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
|
||||
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
|
||||
locally on-device and does not provide services over a network.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,3 +1,50 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
// Load signing config from keystore.properties (gitignored, never commit credentials)
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propsFile = rootProject.file("keystore.properties")
|
||||
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
|
||||
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
|
||||
// 涨到 135MB。仅保留真机需要的两个 ABI。
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
|
||||
localeFilters += listOf(
|
||||
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
|
||||
)
|
||||
// DeepCW 模型必须以未压缩形式打包: ONNX Runtime 通过 mmap 直接读取
|
||||
// assets, 压缩后无法映射会导致 createSession 失败。noCompress 只在
|
||||
// 打包 APK 的 app 模块生效, 在 feature 库模块声明无效。
|
||||
noCompress += "onnx"
|
||||
}
|
||||
signingConfigs {
|
||||
if (keystoreProperties["storeFile"] != null) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
// ONNX Runtime 走 JNI, R8 混淆会重命名 ai.onnxruntime.* 类导致 native
|
||||
// 崩溃。convention 插件已开启 isMinifyEnabled, 必须补 keep 规则。
|
||||
proguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# ProGuard / R8 rules for the Look4Sat application module.
|
||||
#
|
||||
# NOTE: release builds enable minification (isMinifyEnabled=true in the
|
||||
# convention plugin), so anything whose classes are resolved reflectively or
|
||||
# through JNI by name MUST be kept here.
|
||||
|
||||
# ONNX Runtime (ai.onnxruntime): the Java binding is backed by JNI. Native code
|
||||
# resolves Java methods/classes by their original names; R8 renaming or
|
||||
# stripping them causes a hard crash at runtime with no Java stack trace.
|
||||
# This rule is required by the ONNX Runtime docs for minified Android builds.
|
||||
# https://onnxruntime.ai/docs/get-started/with-java.html
|
||||
-keep class ai.onnxruntime.** { *; }
|
||||
-dontwarn ai.onnxruntime.**
|
||||
@@ -14,6 +14,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -45,5 +48,9 @@
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||
|
||||
<service
|
||||
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.rtbishop.look4sat.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.rtbishop.look4sat.MainApplication
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* APRS foreground service: kept alive by a system notification; keeps working across pages.
|
||||
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
|
||||
*/
|
||||
class AprsForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = AprsStore.ACTION_START
|
||||
const val ACTION_STOP = AprsStore.ACTION_STOP
|
||||
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||
const val CHANNEL_ID = "aprs_service"
|
||||
const val NOTIF_ID = 101
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var reporter: AprsReporter? = null
|
||||
private var lastState: AprsState = AprsState.Idle
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> stopReporting()
|
||||
ACTION_REPORT_NOW -> {
|
||||
if (reporter == null) {
|
||||
// Service not running: start it first (Toast hint when not configured)
|
||||
startReporting()
|
||||
}
|
||||
reporter?.reportNow()
|
||||
}
|
||||
else -> startReporting()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startReporting() {
|
||||
// onStartCommand reaches here for every ACTION_START and for the null
|
||||
// intent that START_STICKY delivers on restart. Without this guard each
|
||||
// call built a fresh AprsReporter and overwrote the field, leaving the
|
||||
// previous one running with its own scope and timer: the server then
|
||||
// received one duplicate position report per leaked instance per cycle,
|
||||
// and ACTION_STOP could only ever stop the newest one.
|
||||
reporter?.let { existing ->
|
||||
if (existing.isRunning) return
|
||||
existing.stop()
|
||||
}
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||
runCatching {
|
||||
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
startForegroundWithNotification(cfg)
|
||||
val rep = AprsReporter(
|
||||
configProvider = { AprsStore.loadConfig(this) },
|
||||
positionProvider = { stationPosition() },
|
||||
onState = { lastState = it },
|
||||
onReport = { report ->
|
||||
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||
updateNotification(cfg)
|
||||
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||
val msg = if (report.ok) {
|
||||
getString(R.string.aprs_toast_ok)
|
||||
} else {
|
||||
getString(R.string.aprs_toast_fail, report.detail)
|
||||
}
|
||||
runCatching {
|
||||
Toast.makeText(this, msg,
|
||||
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
reporter = rep
|
||||
rep.start()
|
||||
}
|
||||
|
||||
private fun stopReporting() {
|
||||
reporter?.stop()
|
||||
reporter = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun startForegroundWithNotification(cfg: AprsConfig) {
|
||||
try {
|
||||
val notif = buildNotification(cfg)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notif)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(cfg: AprsConfig): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0, packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val stopPi = PendingIntent.getService(
|
||||
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val stateText = when (lastState) {
|
||||
AprsState.Connected -> getString(R.string.aprs_notif_connected)
|
||||
AprsState.Error -> getString(R.string.aprs_notif_error)
|
||||
else -> getString(R.string.aprs_notif_running)
|
||||
}
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_radios)
|
||||
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
|
||||
.setContentText(stateText)
|
||||
.setContentIntent(pi)
|
||||
.setOngoing(true)
|
||||
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(cfg: AprsConfig) {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIF_ID, buildNotification(cfg))
|
||||
}
|
||||
|
||||
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
|
||||
private fun stationPosition(): Pair<Double, Double>? {
|
||||
// 1. Station QTH (position set in settings)
|
||||
val station = runCatching {
|
||||
val container = (application as MainApplication).getMainContainer()
|
||||
container.settingsRepo.stationPosition.value
|
||||
}.getOrNull()
|
||||
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
|
||||
return Pair(station.latitude, station.longitude)
|
||||
}
|
||||
// 2. Fallback: last live GPS position
|
||||
return runCatching {
|
||||
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
|
||||
val providers = listOf(
|
||||
android.location.LocationManager.GPS_PROVIDER,
|
||||
android.location.LocationManager.NETWORK_PROVIDER
|
||||
)
|
||||
for (p in providers) {
|
||||
val loc = lm.getLastKnownLocation(p) ?: continue
|
||||
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
|
||||
return Pair(loc.latitude, loc.longitude)
|
||||
}
|
||||
}
|
||||
null
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, getString(R.string.aprs_notif_channel),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
reporter?.stop()
|
||||
reporter = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
|
||||
|
||||
private lateinit var container: IMainContainer
|
||||
|
||||
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
|
||||
private fun installCrashHandler() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
val log = StringBuilder()
|
||||
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
|
||||
log.append("Thread: ").append(thread.name).append("\n")
|
||||
val sw = java.io.StringWriter()
|
||||
throwable.printStackTrace(java.io.PrintWriter(sw))
|
||||
log.append(sw.toString()).append("\n")
|
||||
val file = java.io.File(filesDir, "crash_log.txt")
|
||||
file.appendText(log.toString())
|
||||
}
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMainContainer(): IMainContainer = container
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashHandler()
|
||||
container = MainContainer(this)
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -25,8 +32,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -44,7 +49,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -53,10 +58,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -73,6 +82,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -80,44 +90,52 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<Screen.Passes> { MainScreen() }
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,17 +143,40 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen() {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// Menu layout is resolved in core:domain so the bar and the settings editor
|
||||
// cannot disagree, and so Settings can never be pushed out of both menus.
|
||||
val allNavItems = listOf(
|
||||
Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming,
|
||||
Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings
|
||||
)
|
||||
val menuLayout = remember(
|
||||
otherSettings.screenOrder, otherSettings.subMenuOrder, otherSettings.hiddenScreens
|
||||
) {
|
||||
MenuLayout.resolve(
|
||||
allScreenIds = allNavItems.map { it.screenId },
|
||||
screenOrder = otherSettings.screenOrder,
|
||||
subMenuOrder = otherSettings.subMenuOrder,
|
||||
hiddenScreenIds = otherSettings.hiddenScreens
|
||||
)
|
||||
}
|
||||
val mainNavItems = remember(menuLayout) {
|
||||
menuLayout.mainIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
val moreNavItems = remember(menuLayout) {
|
||||
menuLayout.moreIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
// Intercept Back while the More menu is open: close the menu first
|
||||
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
@@ -150,27 +191,41 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
) {
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
navItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
mainNavItems.forEach { screen ->
|
||||
// Screen subclasses are data objects, so identity is enough and
|
||||
// newly added pages highlight without touching this call site.
|
||||
val isSelected = currentKey == screen
|
||||
item(
|
||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
|
||||
if (moreNavItems.isNotEmpty()) {
|
||||
item(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
||||
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
||||
selected = moreExpanded,
|
||||
onClick = { moreExpanded = !moreExpanded }
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
@@ -181,10 +236,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
Box {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
@@ -204,7 +260,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
@@ -224,6 +279,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Roaming> {
|
||||
RoamingScreen()
|
||||
}
|
||||
entry<Screen.CwDecode> {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.AmSat> {
|
||||
SatStatusDestination()
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
@@ -262,7 +329,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
@@ -278,6 +345,31 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// More-menu popup panel (slim strip anchored to the bottom-right corner)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = scaleIn(
|
||||
animationSpec = tween(150),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeIn(animationSpec = tween(150)),
|
||||
exit = scaleOut(
|
||||
animationSpec = tween(120),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeOut(animationSpec = tween(120))
|
||||
) {
|
||||
MoreMenuPopup(
|
||||
items = moreNavItems,
|
||||
currentKey = currentKey,
|
||||
onDismiss = { moreExpanded = false },
|
||||
onSelect = { screen ->
|
||||
moreExpanded = false
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||
*
|
||||
* A slim right-aligned strip above the bottom bar, vertical menu items (icon+text+arrow),
|
||||
* current page highlighted; tap outside to close, tap an item to navigate. No dimming scrim, so the
|
||||
* page stays readable. Open/close animation is driven by the caller (MainScreen's AnimatedVisibility).
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
|
||||
@Composable
|
||||
fun MoreMenuPopup(
|
||||
items: List<Screen>,
|
||||
currentKey: NavKey?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Screen) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Transparent catcher: taps outside the card dismiss the menu without
|
||||
// dimming the page behind it.
|
||||
.clickable(onClick = onDismiss)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(12.dp)
|
||||
.widthIn(max = 232.dp)
|
||||
// Swallow taps on the card so they do not reach the dismiss
|
||||
// catcher underneath.
|
||||
.clickable(enabled = false) {},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.forEach { screen ->
|
||||
// Screen subclasses are data objects, so identity is enough. The
|
||||
// old per-type when was missing AmSat and Roaming, leaving those
|
||||
// pages unhighlighted while open.
|
||||
val isSelected = currentKey == screen
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(screen) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(screen.titleResId),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "›",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
||||
+3
@@ -31,12 +31,15 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:cw"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:roaming"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(project(":feature:status"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation(libs.compose.navigation3)
|
||||
|
||||
@@ -5,3 +5,9 @@ plugins {
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.core.data"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
|
||||
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
|
||||
implementation(libs.other.onnxruntime)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
/**
|
||||
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
||||
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
||||
*/
|
||||
class AprsIsClient(
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
private val callsign: String,
|
||||
private val ssid: String,
|
||||
private val passcode: Int,
|
||||
private val version: String,
|
||||
private val filter: String = "",
|
||||
private val timeoutSec: Int = 120
|
||||
) {
|
||||
private var socket: Socket? = null
|
||||
private var writer: PrintWriter? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private val lock = Any()
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||
|
||||
/** Connect + login (synchronous/blocking; call from a background thread) */
|
||||
@Throws(Exception::class)
|
||||
fun connect() {
|
||||
disconnect()
|
||||
val s = Socket()
|
||||
try {
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// Login line
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
}
|
||||
// Restore timeout
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Close the local socket before re-throwing, so it does not leak when
|
||||
// an exception is raised after s.connect() but before socket = s.
|
||||
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
||||
// accumulate leaked fds until the process cannot open any more files.
|
||||
runCatching { s.close() }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one APRS packet (one line) and tries to read the server ack.
|
||||
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
||||
*/
|
||||
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||
synchronized(lock) {
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
// Read the server response inside the same lock: disconnect() (called
|
||||
// concurrently from stop()/reconnect on another thread) nulls
|
||||
// writer/reader/socket and closes them. Reading outside the lock raced
|
||||
// with that: the response read could hit a just-closed socket and the
|
||||
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
||||
// never left, or read through a stale reference. Serialising keeps
|
||||
// the read on the connection this thread just wrote to. The 3 s read
|
||||
// timeout bounds how long a concurrent disconnect waits.
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one line (server response; throws on timeout) */
|
||||
fun readLine(): String? {
|
||||
return reader?.readLine()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
synchronized(lock) {
|
||||
runCatching { writer?.close() }
|
||||
runCatching { reader?.close() }
|
||||
runCatching { socket?.close() }
|
||||
writer = null
|
||||
reader = null
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** APRS connection state */
|
||||
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
|
||||
|
||||
/** APRS config (persisted in SharedPreferences, saved as filled) */
|
||||
data class AprsConfig(
|
||||
val enabled: Boolean = false,
|
||||
val server: String = "euro.aprs2.net",
|
||||
val port: Int = 14580,
|
||||
val callsign: String = "",
|
||||
val ssid: String = "",
|
||||
val passcode: String = "",
|
||||
val intervalMin: Int = 5,
|
||||
val statusText: String = "Look4Sat APRS",
|
||||
val symbolTable: String = "/",
|
||||
val symbolCode: String = ">",
|
||||
val includeCourseSpeed: Boolean = true,
|
||||
val includeAltitude: Boolean = true
|
||||
)
|
||||
|
||||
/** APRS report result */
|
||||
data class AprsReport(
|
||||
val timestamp: Long,
|
||||
val packet: String,
|
||||
val ok: Boolean,
|
||||
val detail: String
|
||||
)
|
||||
|
||||
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||
class AprsReporter(
|
||||
private val configProvider: () -> AprsConfig,
|
||||
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||
private val onState: (AprsState) -> Unit = {},
|
||||
private val onReport: (AprsReport) -> Unit = {}
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var client: AprsIsClient? = null
|
||||
private var job: Job? = null
|
||||
private var manualJob: Job? = null
|
||||
|
||||
val isRunning: Boolean get() = job?.isActive == true
|
||||
|
||||
/** Start periodic reporting (called by the foreground service) */
|
||||
fun start() {
|
||||
stop()
|
||||
val cfg = configProvider()
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||
onState(AprsState.Error)
|
||||
return
|
||||
}
|
||||
job = scope.launch {
|
||||
while (isActive) {
|
||||
reportOnce()
|
||||
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
runCatching { client?.disconnect() }
|
||||
client = null
|
||||
onState(AprsState.Idle)
|
||||
}
|
||||
|
||||
/** Trigger one report manually (immediately, without waiting for the cycle) */
|
||||
fun reportNow() {
|
||||
manualJob?.cancel()
|
||||
manualJob = scope.launch { reportOnce() }
|
||||
}
|
||||
|
||||
private suspend fun reportOnce() {
|
||||
val cfg = configProvider()
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||
onState(AprsState.Connecting)
|
||||
try {
|
||||
val c = client ?: AprsIsClient(
|
||||
host = cfg.server,
|
||||
port = cfg.port,
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||
version = "Look4Sat 4.5.4"
|
||||
).also { client = it }
|
||||
if (!c.isConnected) c.connect()
|
||||
onState(AprsState.Connected)
|
||||
|
||||
val pos = positionProvider()
|
||||
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||
val result = c.sendPacket(packetLine)
|
||||
val ok = result?.first == true
|
||||
val detail = result?.second ?: "no connection"
|
||||
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||
} catch (e: Exception) {
|
||||
runCatching { client?.disconnect() }
|
||||
client = null
|
||||
onState(AprsState.Error)
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||
}
|
||||
}
|
||||
|
||||
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
|
||||
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||
val pos = AprsPosition(
|
||||
latitude = lat ?: 0.0,
|
||||
longitude = lon ?: 0.0,
|
||||
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||
)
|
||||
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* APRS config storage + service action constants (shared by feature/settings and the app service,
|
||||
* so feature never depends on app).
|
||||
*/
|
||||
object AprsStore {
|
||||
|
||||
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
|
||||
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
|
||||
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
|
||||
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
|
||||
|
||||
private const val PREFS = "aprs_config"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_SERVER = "server"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_CALLSIGN = "callsign"
|
||||
private const val KEY_SSID = "ssid"
|
||||
private const val KEY_PASSCODE = "passcode"
|
||||
private const val KEY_INTERVAL = "interval"
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||
private const val KEY_LAST_TIME = "last_report_time"
|
||||
private const val KEY_LAST_OK = "last_report_ok"
|
||||
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||
|
||||
/** Read config (saved as filled; no need to re-enter each time) */
|
||||
fun loadConfig(context: Context): AprsConfig {
|
||||
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return AprsConfig(
|
||||
enabled = p.getBoolean(KEY_ENABLED, false),
|
||||
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
|
||||
port = p.getInt(KEY_PORT, 14580),
|
||||
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
|
||||
ssid = p.getString(KEY_SSID, "") ?: "",
|
||||
passcode = p.getString(KEY_PASSCODE, "") ?: "",
|
||||
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||
)
|
||||
}
|
||||
|
||||
/** Last report result (shown on the settings card) */
|
||||
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
|
||||
|
||||
fun loadLastReport(context: Context): LastReport {
|
||||
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return LastReport(
|
||||
time = p.getLong(KEY_LAST_TIME, 0L),
|
||||
ok = p.getBoolean(KEY_LAST_OK, false),
|
||||
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
|
||||
.putBoolean(KEY_LAST_OK, ok)
|
||||
.putString(KEY_LAST_DETAIL, detail)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Save config */
|
||||
fun saveConfig(context: Context, cfg: AprsConfig) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putBoolean(KEY_ENABLED, cfg.enabled)
|
||||
.putString(KEY_SERVER, cfg.server)
|
||||
.putInt(KEY_PORT, cfg.port)
|
||||
.putString(KEY_CALLSIGN, cfg.callsign)
|
||||
.putString(KEY_SSID, cfg.ssid)
|
||||
.putString(KEY_PASSCODE, cfg.passcode)
|
||||
.putInt(KEY_INTERVAL, cfg.intervalMin)
|
||||
.putString(KEY_STATUS, cfg.statusText)
|
||||
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
|
||||
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
/**
|
||||
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
|
||||
* `feature/cw/licenses/NOTICE.md`).
|
||||
*
|
||||
* The model classifies a whole audio segment at once rather than streaming
|
||||
* sample by sample, and it revises earlier characters once more context
|
||||
* arrives. Incremental stitching therefore produces duplicated callsigns —
|
||||
* measured character error rates of 67-294% against 0% for whole-segment
|
||||
* decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole
|
||||
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
|
||||
*
|
||||
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
|
||||
* in; no spectral peak tracking or squelch gating is needed. A tone outside that
|
||||
* window is invisible to the model, so [CwToneShifter] can optionally move it in —
|
||||
* see [isToneShiftEnabled].
|
||||
*
|
||||
* @param isToneShiftEnabled read on every chunk so toggling the setting takes effect
|
||||
* without rebuilding the decoder. Defaults to disabled: with it off the audio path
|
||||
* is byte-for-byte what it was before the feature existed.
|
||||
*/
|
||||
class CwDeepDecoder(
|
||||
context: Context,
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
* Samples the detector needs for a usable estimate: 0.4 s at 3200 Hz, giving
|
||||
* ~12.5 Hz resolution.
|
||||
*
|
||||
* A capture chunk is ~100 ms, which is 4410 samples at the 44.1 kHz capture
|
||||
* rate but only 320 after resampling to 3200 Hz. Gating on a single chunk
|
||||
* reaching this size would therefore never fire, so chunks are accumulated in
|
||||
* [detectionPool] until enough audio is available.
|
||||
*/
|
||||
const val DETECT_MIN_SAMPLES = 1280
|
||||
|
||||
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
|
||||
const val DETECT_INTERVAL_MS = 2000
|
||||
|
||||
/** Silence after which a tone reading is treated as stale. See runDetection. */
|
||||
const val TONE_EXPIRY_MS = 10_000L
|
||||
|
||||
/**
|
||||
* Minimum change in the required shift before the window is re-shifted.
|
||||
*
|
||||
* Two scan bins (12.5 Hz each) plus margin. Re-shifting drops the 20 s decode
|
||||
* window, so a tone drifting slightly - or the estimate hopping to an adjacent
|
||||
* bin - must not keep wiping context that is still perfectly decodable.
|
||||
*/
|
||||
const val SHIFT_HYSTERESIS_HZ = 40f
|
||||
}
|
||||
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
private val _detectedToneHz = MutableStateFlow<Float?>(null)
|
||||
override val detectedToneHz: StateFlow<Float?> = _detectedToneHz.asStateFlow()
|
||||
|
||||
private val _activeShiftHz = MutableStateFlow(0f)
|
||||
override val activeShiftHz: StateFlow<Float> = _activeShiftHz.asStateFlow()
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
|
||||
|
||||
private val _lastInferenceMs = MutableStateFlow(0)
|
||||
override val lastInferenceMs: StateFlow<Int> = _lastInferenceMs.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
||||
* keeps the extra inference cheap (short window) while long enough to be
|
||||
* decoded accurately — the content has already been through the 20 s window
|
||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/** Decides what shift to apply from successive tone estimates. */
|
||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||
|
||||
/** Wall clock of the last scan that actually found a tone, for [TONE_EXPIRY_MS]. */
|
||||
private var lastToneAtMs = 0L
|
||||
|
||||
/** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */
|
||||
private var lastDetectAtMs = 0L
|
||||
|
||||
/**
|
||||
* Pools resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single capture
|
||||
* chunk is only 320 samples once resampled, so detection has to pool several.
|
||||
*/
|
||||
private val detectionPool = CwDetectionPool(DETECT_MIN_SAMPLES)
|
||||
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/**
|
||||
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||
* Null until the first chunk: a decoder created while the setting is already on
|
||||
* must not treat that as a change and wipe an empty buffer.
|
||||
*/
|
||||
private var toneShiftWasEnabled: Boolean? = null
|
||||
|
||||
private var environment: OrtEnvironment? = null
|
||||
private var session: OrtSession? = null
|
||||
private var chars: List<String> = emptyList()
|
||||
private var blankIndex = 41
|
||||
private var inputName = "spectrogram"
|
||||
private var outputName = "log_probs"
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private var loadAttempted = false
|
||||
|
||||
init {
|
||||
CwProbe.init(appContext)
|
||||
CwProbe.step("decoder_constructed")
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads metadata and the ONNX session on first use.
|
||||
*
|
||||
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
|
||||
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
|
||||
* from a constructor it would take down the whole composable that created
|
||||
* the decoder, so the work happens here where it can be reported through
|
||||
* [errorMessage] instead.
|
||||
*
|
||||
* @return true when the session is ready to run.
|
||||
*/
|
||||
private fun ensureLoaded(): Boolean {
|
||||
if (session != null) return true
|
||||
if (loadAttempted) return false
|
||||
loadAttempted = true
|
||||
CwProbe.step("load_begin")
|
||||
try {
|
||||
val metadata = JSONObject(
|
||||
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
|
||||
)
|
||||
val charArray = metadata.getJSONArray("chars")
|
||||
chars = List(charArray.length()) { charArray.getString(it) }
|
||||
blankIndex = metadata.getInt("blank_index")
|
||||
inputName = metadata.getString("onnx_input_name")
|
||||
outputName = metadata.getString("onnx_output_name")
|
||||
|
||||
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||
val env = OrtEnvironment.getEnvironment()
|
||||
environment = env
|
||||
val options = OrtSession.SessionOptions().apply {
|
||||
// Keep a core free for audio capture and the UI; the default
|
||||
// would spread inference across every core on the device.
|
||||
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
|
||||
setIntraOpNumThreads(threads)
|
||||
}
|
||||
session = env.createSession(modelBytes, options)
|
||||
CwProbe.step("load_session_ok")
|
||||
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
|
||||
return true
|
||||
} catch (t: Throwable) {
|
||||
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
|
||||
// asset and session failures.
|
||||
CwProbe.step("load_failed:${t.javaClass.simpleName}")
|
||||
Log.e(TAG, "DeepCW model failed to load", t)
|
||||
_errorMessage.value =
|
||||
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
|
||||
// Persist the failure for devices without logcat access.
|
||||
runCatching {
|
||||
val sw = java.io.StringWriter()
|
||||
t.printStackTrace(java.io.PrintWriter(sw))
|
||||
java.io.File(appContext.filesDir, "deepcw_load_error.txt")
|
||||
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val prepared = applyToneShift(resampled)
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
// when a full archive chunk has accumulated, so old text does not vanish.
|
||||
val overflow = buffer.drainOverflow()
|
||||
if (overflow.isNotEmpty()) {
|
||||
for (v in overflow) {
|
||||
// Flush before appending when the buffer is full, so large batches
|
||||
// (e.g. 47999 samples already accumulated + 64000 new overflow)
|
||||
// do not silently drop audio that scrolled out of the live window.
|
||||
if (archiveSize >= archiveBuffer.size) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
try {
|
||||
archiveDecode(audio)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "archive decode failed", t)
|
||||
}
|
||||
}
|
||||
archiveBuffer[archiveSize++] = v
|
||||
}
|
||||
// Final flush when threshold is reached (e.g. exactly 48000 accumulated).
|
||||
if (archiveSize >= ARCHIVE_THRESHOLD) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
try {
|
||||
archiveDecode(audio)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "archive decode failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRedecode || !buffer.hasEnoughAudio) return
|
||||
|
||||
// Drop this cycle rather than queue when the previous run is still going.
|
||||
if (!inferenceLock.tryLock()) {
|
||||
Log.d(TAG, "inference still running, skipping this interval")
|
||||
return
|
||||
}
|
||||
try {
|
||||
decodeWindow(buffer.snapshot())
|
||||
} catch (t: Throwable) {
|
||||
// Cancellation is normal when the user pauses: the capture coroutine
|
||||
// is cancelled while an inference is in flight. Never swallow it as
|
||||
// a decode error — rethrow so the coroutine machinery works, and do
|
||||
// not flash a spurious "decode failed" banner.
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "inference failed", t)
|
||||
_errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}"
|
||||
runCatching {
|
||||
val sw = java.io.StringWriter()
|
||||
t.printStackTrace(java.io.PrintWriter(sw))
|
||||
java.io.File(appContext.filesDir, "deepcw_infer_error.txt")
|
||||
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||
}
|
||||
} finally {
|
||||
inferenceLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an out-of-window tone into the model's analysis window when the user has
|
||||
* enabled it.
|
||||
*
|
||||
* The detection scan is a bin-by-bin DFT, so it runs at most every
|
||||
* [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it
|
||||
* produces is cached in [_activeShiftHz] and applied to the chunks in between. A
|
||||
* tone already inside the window yields a zero shift, and then this returns the
|
||||
* caller's array untouched.
|
||||
*
|
||||
* @return the audio to buffer: [resampled] itself whenever no shift applies.
|
||||
*/
|
||||
private fun applyToneShift(resampled: FloatArray): FloatArray {
|
||||
val enabled = isToneShiftEnabled()
|
||||
|
||||
// A toggle invalidates whatever is already buffered: those samples were moved by
|
||||
// the old setting and cannot be un-shifted, so the 20 s window would keep
|
||||
// decoding them - and the pitch readout would correct them by the wrong amount -
|
||||
// for up to 20 s after the user acted. Seeded from the current setting on the
|
||||
// first chunk so starting up with it already on is not treated as a change.
|
||||
val previousEnabled = toneShiftWasEnabled ?: enabled
|
||||
toneShiftWasEnabled = enabled
|
||||
if (enabled != previousEnabled) {
|
||||
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
|
||||
dropBufferedAudio()
|
||||
_activeShiftHz.value = 0f
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
streamingShifter.reset()
|
||||
}
|
||||
|
||||
// Detection runs whether or not shifting is enabled. It is the only measurement
|
||||
// that can see past the model's window, so with it skipped an out-of-window tone
|
||||
// left the UI with nothing truthful to show: the spectrogram's own pitch readout
|
||||
// is arithmetically confined to the window and reports the leakage piled against
|
||||
// the nearest edge, so a 1500 Hz tone published "1200 Hz" and a healthy signal
|
||||
// level while decoding nothing at all.
|
||||
detectionPool.add(resampled)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastDetectAtMs
|
||||
if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) {
|
||||
lastDetectAtMs = now
|
||||
runDetection(detectionPool.drain(), shiftEnabled = enabled)
|
||||
}
|
||||
|
||||
if (!enabled) return resampled
|
||||
|
||||
// Streaming keeps the Hilbert filter history and mixer phase across chunks;
|
||||
// shifting each chunk in isolation distorted the 62 samples at its edges.
|
||||
return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard buffered audio that was shifted by a now-stale amount.
|
||||
*
|
||||
* The live window and the pending archive chunk both hold shifted samples that
|
||||
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||
*/
|
||||
private fun dropBufferedAudio() {
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one detection to [shiftDecider] and log what it decided.
|
||||
*
|
||||
* The rule itself lives in core:domain so it can be tested directly; keeping it here
|
||||
* meant tests could only restate it, and a restated rule cannot fail when the real
|
||||
* one is wrong - four injected defects once left the whole suite green.
|
||||
*/
|
||||
private fun runDetection(sample: FloatArray, shiftEnabled: Boolean) {
|
||||
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
|
||||
// Published either way: the UI needs the real pitch to say why nothing decodes
|
||||
// when shifting is off and the tone is out of range. Held through silences for
|
||||
// the same reason the shift is - CW is gaps, and a gap is not a retune - but not
|
||||
// indefinitely: without an expiry the last out-of-band reading survived every
|
||||
// silent scan, so after retuning into the band the hint kept naming a frequency
|
||||
// the operator had left. Ten seconds clears comfortably any real gap, the longest
|
||||
// being about 1.7 s at 5 WPM between words plus a few seconds of thinking.
|
||||
val tone = analysis.toneHz
|
||||
if (tone != null) {
|
||||
_detectedToneHz.value = tone
|
||||
lastToneAtMs = System.currentTimeMillis()
|
||||
} else if (System.currentTimeMillis() - lastToneAtMs > TONE_EXPIRY_MS) {
|
||||
_detectedToneHz.value = null
|
||||
}
|
||||
|
||||
if (!shiftEnabled) return
|
||||
|
||||
val decision = shiftDecider.accept(analysis)
|
||||
_activeShiftHz.value = decision.shiftHz
|
||||
|
||||
when (decision.outcome) {
|
||||
CwShiftDecider.Outcome.NO_TONE -> Log.d(
|
||||
TAG,
|
||||
"toneShift: no tone in ${sample.size} samples, keeping shift=${decision.shiftHz}Hz"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.WITHIN_HYSTERESIS -> Log.d(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz within ${CwShiftDecider.DEFAULT_HYSTERESIS_HZ}Hz " +
|
||||
"of anchor ${shiftDecider.anchorToneHz}Hz, keeping shift=${decision.shiftHz}Hz"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.NO_SHIFT_NEEDED -> Log.d(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz inside " +
|
||||
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.SHIFTED -> Log.i(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz outside window, " +
|
||||
"shifting ${decision.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
|
||||
)
|
||||
}
|
||||
|
||||
if (decision.changed) {
|
||||
// The window still holds audio moved by the old amount. Mixing two shifts in
|
||||
// one spectrogram smears the tone, and the pitch readout could only be right
|
||||
// for one of them, so rebuild the window from the new shift.
|
||||
Log.i(TAG, "toneShift: shift changed, dropping buffered audio")
|
||||
dropBufferedAudio()
|
||||
streamingShifter.reset()
|
||||
CwProbe.step("tone_shift tone=${decision.toneHz} shift=${decision.shiftHz}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
CwProbe.step("infer_begin frames=${window.size}")
|
||||
|
||||
val spectrogram = CwDeepSpectrogram.compute(window)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
|
||||
// Replace, never append: the model rewrites earlier characters as more
|
||||
// context arrives, so appending would leave stale guesses on screen.
|
||||
_decodedText.value = text
|
||||
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
if (text.isNotEmpty()) {
|
||||
_historyText.value += text
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
private fun runInference(
|
||||
activeSession: OrtSession,
|
||||
activeEnvironment: OrtEnvironment,
|
||||
spectrogram: Array<FloatArray>
|
||||
): String {
|
||||
val frames = spectrogram.size
|
||||
val bins = CwDeepSpectrogram.FREQUENCY_BINS
|
||||
|
||||
val flat = FloatBuffer.allocate(frames * bins)
|
||||
for (frame in spectrogram) flat.put(frame)
|
||||
flat.rewind()
|
||||
|
||||
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val text: String
|
||||
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
|
||||
activeSession.run(mapOf(inputName to input)).use { result ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val logits = result[outputName].get().value as Array<Array<FloatArray>>
|
||||
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
|
||||
}
|
||||
}
|
||||
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
|
||||
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the loudest bin as the tone pitch and its prominence over the
|
||||
* window mean as a 0..1 strength, purely for the UI readout.
|
||||
*/
|
||||
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
|
||||
if (spectrogram.isEmpty()) return
|
||||
var bestBin = 0
|
||||
var bestValue = 0f
|
||||
var total = 0f
|
||||
var count = 0
|
||||
for (frame in spectrogram) {
|
||||
for (bin in frame.indices) {
|
||||
val value = frame[bin]
|
||||
total += value
|
||||
count++
|
||||
if (value > bestValue) {
|
||||
bestValue = value
|
||||
bestBin = bin
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count == 0 || bestValue <= 0f) return
|
||||
|
||||
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
|
||||
val absoluteBin = 32 + bestBin
|
||||
// Undo the shift before reporting: the spectrogram sees the moved tone, but
|
||||
// the readout must show the pitch the operator actually hears on the radio.
|
||||
_estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat()
|
||||
|
||||
val mean = total / count
|
||||
val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||
|
||||
// The meter claims something decodable is present, so it needs a tone the scan has
|
||||
// actually confirmed inside the window - not merely the absence of a confirmed
|
||||
// out-of-window one. Requiring the confirmation is what covers the intermittent
|
||||
// case: a slow fist out of band at 15% duty scores 2.5 against MIN_PROMINENCE 4.5,
|
||||
// so no tone is reported, and a condition keyed on "confirmed outside" stayed false
|
||||
// and let the meter read half scale on window-edge leakage beside an empty
|
||||
// transcript - the exact reading this gate exists to suppress.
|
||||
val confirmed = _detectedToneHz.value
|
||||
val decodable = confirmed != null &&
|
||||
(_activeShiftHz.value != 0f || CwToneShifter.isInsideWindow(confirmed))
|
||||
_signalStrength.value = if (decodable) prominence else 0f
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
buffer.reset()
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
_signalStrength.value = 0f
|
||||
_lastInferenceMs.value = 0
|
||||
// Re-detect from scratch: the operator may have retuned before resetting.
|
||||
_activeShiftHz.value = 0f
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
streamingShifter.reset()
|
||||
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
|
||||
// current setting instead of reporting a spurious change.
|
||||
toneShiftWasEnabled = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
session?.close()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "session close failed", t)
|
||||
}
|
||||
session = null
|
||||
environment = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Minimal crash-probe logger for diagnosing crashes that produce no Java
|
||||
* stack trace (native faults, low-memory kills). Each step appends one line
|
||||
* to `files/probe_cw.txt`; if the process dies mid-way the last line shows
|
||||
* exactly where. No adb or logcat required.
|
||||
*/
|
||||
internal object CwProbe {
|
||||
|
||||
/** Keep the diagnostic file bounded: the decoder writes two lines per
|
||||
* 1.5 s inference tick (~170 KB/hour), so without a cap it grows without
|
||||
* limit on every release build. Truncate instead of deleting so the
|
||||
* probe keeps the last diagnostics before a crash. */
|
||||
private const val MAX_FILE_BYTES = 1_048_576L // 1 MiB
|
||||
|
||||
private var dir: java.io.File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
dir = context.filesDir
|
||||
}
|
||||
|
||||
fun step(label: String) {
|
||||
val target = dir ?: return
|
||||
runCatching {
|
||||
val line = "${System.currentTimeMillis()} $label"
|
||||
val file = java.io.File(target, "probe_cw.txt")
|
||||
if (file.length() > MAX_FILE_BYTES) file.delete()
|
||||
file.appendText("$line\n")
|
||||
Log.i("CwProbe", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||
"""
|
||||
)
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
|
||||
+14
@@ -76,10 +76,12 @@ class BluetoothReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
rotatorSocket = socket
|
||||
rotatorStream = socket.outputStream
|
||||
@@ -87,6 +89,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
rotatorSocket = null
|
||||
rotatorStream = null
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
@@ -97,10 +104,12 @@ class BluetoothReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
frequencySocket = socket
|
||||
frequencyStream = socket.outputStream
|
||||
@@ -108,6 +117,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
frequencySocket = null
|
||||
frequencyStream = null
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
|
||||
@@ -42,6 +42,9 @@ class Ft817Controller(
|
||||
private val commandDelayMs = 200L
|
||||
private val maxAckReadFailures = 3
|
||||
|
||||
/** Largest frequency the 4-byte BCD / 10 Hz CAT field can represent. */
|
||||
private val maxFrequencyHz = 999_999_990L
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
@@ -53,9 +56,11 @@ class Ft817Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -66,6 +71,13 @@ class Ft817Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
@@ -91,6 +103,16 @@ class Ft817Controller(
|
||||
}
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
// The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution,
|
||||
// so the protocol cannot express anything above 999,999,990 Hz. Below
|
||||
// that the encoder is exact; above it, the %08d formatting silently
|
||||
// drops the leading digit and the radio receives a frequency ten
|
||||
// times lower (e.g. 1267.6 MHz becomes 126.76 MHz), and the tracking
|
||||
// loop's read-back then locks onto the wrong band. Reject instead.
|
||||
if (frequencyHz > maxFrequencyHz) {
|
||||
Log.e(tag, "setFrequency rejected: $frequencyHz Hz exceeds FT-817 CAT limit $maxFrequencyHz")
|
||||
return@withContext false
|
||||
}
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ class Ic705Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -84,6 +86,13 @@ class Ic705Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
|
||||
+21
-2
@@ -71,14 +71,19 @@ class NetworkReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorSocket = opened
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
rotatorConnected = false
|
||||
// Close a socket that connected but failed during setup, so a
|
||||
// broken channel is never left referenced without a closer.
|
||||
opened?.close()
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
}
|
||||
@@ -88,14 +93,17 @@ class NetworkReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencySocket = opened
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
frequencyConnected = false
|
||||
opened?.close()
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
}
|
||||
@@ -111,6 +119,17 @@ class NetworkReporter(
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
onError()
|
||||
// The channel failed a write: drop it and its closure obligation.
|
||||
// Leaving it referenced lets the next connect overwrite the field
|
||||
// and leak the old channel. Only the field the caller passed is
|
||||
// cleared, matching the connected=false the onError sets.
|
||||
if (socket === rotatorSocket) {
|
||||
rotatorSocket?.close()
|
||||
rotatorSocket = null
|
||||
} else if (socket === frequencySocket) {
|
||||
frequencySocket?.close()
|
||||
frequencySocket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -267,14 +267,20 @@ class RadioTrackingService(
|
||||
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||
txNow.setFrequency(txRadioFreq)
|
||||
// Only remember the frequency we actually wrote: if the radio
|
||||
// rejects it (FT-817 CAT limit) or the link dropped, keeping
|
||||
// lastSetTxFreq updated would make the manual-tuning detector
|
||||
// see a phantom dial change on the next read-back.
|
||||
if (txNow.setFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||
rxNow.setFrequency(rxRadioFreq)
|
||||
if (rxNow.setFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
@@ -450,15 +456,17 @@ class RadioTrackingService(
|
||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||
if (rxRadioFreq != null) {
|
||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||
radio.setWorkingFrequency(rxRadioFreq)
|
||||
if (radio.setWorkingFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (txRadioFreq != null) {
|
||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||
radio.setTxVfoFrequency(txRadioFreq)
|
||||
if (radio.setTxVfoFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
@@ -40,6 +41,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -66,16 +70,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource by lazy { provideRemoteSource() }
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
@@ -90,12 +97,43 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||
try {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||
} catch (_: SecurityException) {}
|
||||
}
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
|
||||
// 面板与独立 CW 页各自持有自己的解码器
|
||||
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
|
||||
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context) {
|
||||
// Read per chunk so toggling the setting applies without restarting capture.
|
||||
settingsRepo.otherSettings.value.cwToneShiftEnabled
|
||||
}
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
|
||||
override val wavelogQueue: WavelogQueue by lazy {
|
||||
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
||||
WavelogQueue(object : IWavelogQueueStore {
|
||||
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
||||
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
||||
})
|
||||
}
|
||||
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
||||
|
||||
private val lotwRepo: LotwSatellitesRepo by lazy {
|
||||
LotwSatellitesRepo(context).also { it.restore() }
|
||||
}
|
||||
|
||||
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
@@ -149,7 +187,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
val remoteSource = provideRemoteSource()
|
||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
}
|
||||
|
||||
@@ -160,7 +197,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
return RemoteSource(
|
||||
Dispatchers.IO, context.contentResolver,
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
@@ -176,6 +220,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* One report from the AMSAT API (data layer model).
|
||||
*
|
||||
* Internal rather than private so [AmSatRepository.buildStatuses] can be unit-tested:
|
||||
* the JSON parsing around it needs Android's JSONObject, which is a stub on the JVM.
|
||||
*/
|
||||
internal data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
|
||||
// The summary endpoint tells us how many reports each satellite actually has,
|
||||
// independent of the 500-record cap. Mark any satellite whose global pull is
|
||||
// incomplete so the UI can show a data-coverage note.
|
||||
val summaryJson = remoteSource.getAmSatSummary(hours = 72)
|
||||
val expectedCounts = parseSummary(summaryJson)
|
||||
val marked = statuses.map { status ->
|
||||
val expected = expectedCounts[status.name]
|
||||
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||
if (expected != null && expected > actual) status.copy(summaryCount = expected)
|
||||
else status
|
||||
}
|
||||
|
||||
SatStatusPage(System.currentTimeMillis(), marked, reportMap)
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse summary JSON to per-satellite report counts.
|
||||
*
|
||||
* The summary aggregates across all statuses, so a satellite with both "heard" and
|
||||
* "not heard" entries appears once; we sum its report_count across all its rows.
|
||||
* Returns an empty map (not null) on failure so the caller can just check for
|
||||
* missing keys — a failed summary call degrades gracefully to "no coverage marker".
|
||||
*/
|
||||
private fun parseSummary(json: String?): Map<String, Int> {
|
||||
if (json == null) return emptyMap()
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
val out = mutableMapOf<String, Int>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name", "")
|
||||
val count = o.optInt("report_count", 0)
|
||||
if (name.isNotEmpty() && count > 0) {
|
||||
out[name] = (out[name] ?: 0) + count
|
||||
}
|
||||
}
|
||||
out
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one SatStatus (3 days x 12 two-hour slots) per catalog satellite.
|
||||
*
|
||||
* Days are UTC calendar days and slots are fixed UTC bands, matching amsat.org: day 0
|
||||
* is today, its slot 0 covers 22:00-24:00 UTC and slot 11 covers 00:00-02:00, so both
|
||||
* the day list and the slots inside it read newest-first.
|
||||
*
|
||||
* A rolling window anchored on "now" was wrong: fetching at 06:07 UTC put 17.9 hours
|
||||
* of yesterday into the cell labelled today. Checked against a live amsat.org page of
|
||||
* 1021 reports, 73% landed in the wrong day column.
|
||||
*/
|
||||
internal fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
|
||||
// Midnight UTC today, the anchor every slot boundary is derived from.
|
||||
utc.timeInMillis = nowSec * 1000
|
||||
utc.set(Calendar.HOUR_OF_DAY, 0)
|
||||
utc.set(Calendar.MINUTE, 0)
|
||||
utc.set(Calendar.SECOND, 0)
|
||||
utc.set(Calendar.MILLISECOND, 0)
|
||||
val todayMidnightSec = utc.timeInMillis / 1000
|
||||
|
||||
// Reuses the same Calendar, which is safe only because each pass assigns
|
||||
// timeInMillis outright rather than adjusting fields. After this loop it points at
|
||||
// the oldest day, so anything added below must set the time again before reading.
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
// Oldest report across the whole response, marking how far back the data reaches.
|
||||
// Taken globally rather than per satellite: a quiet satellite has no reports of its
|
||||
// own, but the slots it shares with the rest of the response were still covered.
|
||||
//
|
||||
// Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time
|
||||
// fails to parse, and a single such record would drag this back to 1970 and mark
|
||||
// nothing as uncovered, silently reverting the distinction.
|
||||
val dataFromSec = reports.asSequence()
|
||||
.map { it.reportedTimeUtcSec }
|
||||
.filter { it > 0L }
|
||||
.minOrNull()
|
||||
?: todayMidnightSec
|
||||
|
||||
return names.map { name ->
|
||||
val satReports = byName[name].orEmpty()
|
||||
val days = (0 until 3).map { dayIdx ->
|
||||
val dayStart = todayMidnightSec - dayIdx * 86400L
|
||||
val slots = (0 until 12).map { slotIdx ->
|
||||
// Slot 0 is the last band of the day, so the day reads newest-first.
|
||||
val slotStart = dayStart + (11 - slotIdx) * 7200L
|
||||
val slotEnd = slotStart + 7200L
|
||||
val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
// A slot entirely before the data starts is unknown, not silent.
|
||||
val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY
|
||||
SatSlot(statusColor = colour, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
SatDay(dateLabel = labels[dayIdx], slots = slots)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val y = cal.get(Calendar.YEAR)
|
||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||
return SatReport(
|
||||
id = r.id,
|
||||
statusText = r.report,
|
||||
call = r.callsign,
|
||||
grid = r.gridSquare,
|
||||
dateUtc = "$y-$mo-$d",
|
||||
timeUtc = "$hh:$mm UTC"
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
// AMSAT official status colors (from amsat.org/status)
|
||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
|
||||
/**
|
||||
* Slots older than the data we actually received.
|
||||
*
|
||||
* The API caps at 500 records however many hours are requested. Measured live: a
|
||||
* 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest
|
||||
* 9.5 hours of the third day with no data at all. Painting those the same grey as
|
||||
* "nobody reported" claimed knowledge we do not have, so they get a lighter shade.
|
||||
*/
|
||||
private const val NO_DATA_GRAY = 0xFFE8E8E8
|
||||
}
|
||||
}
|
||||
@@ -69,23 +69,33 @@ class DatabaseRepo(
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
|
||||
+25
-3
@@ -34,9 +34,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -50,6 +54,10 @@ class SatelliteRepo(
|
||||
private val _isCalculating = MutableStateFlow(false)
|
||||
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||
|
||||
// Serializes pass calculation. Callers queue instead of being dropped: a
|
||||
// dropped call would silently discard the filter the user just applied.
|
||||
private val calculationMutex = Mutex()
|
||||
|
||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||
|
||||
@@ -63,7 +71,11 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
@@ -73,7 +85,7 @@ class SatelliteRepo(
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +129,12 @@ class SatelliteRepo(
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
) {
|
||||
// Queue behind any in-flight calculation rather than dropping this call:
|
||||
// every invocation carries filter settings the user just chose, so a
|
||||
// dropped one leaves the list showing results for the previous filter.
|
||||
calculationMutex.withLock {
|
||||
_isCalculating.value = true
|
||||
try {
|
||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
@@ -155,8 +172,13 @@ class SatelliteRepo(
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
} finally {
|
||||
// finally: a thrown/cancelled calculation must not leave the
|
||||
// progress indicator spinning forever.
|
||||
_isCalculating.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAosInRange(
|
||||
aosTime: Long,
|
||||
@@ -170,7 +192,7 @@ class SatelliteRepo(
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
aosMinute in aosStartMinute..aosEndMinute
|
||||
} else {
|
||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||
}
|
||||
return if (invertAosTimeWindow) !inRange else inRange
|
||||
}
|
||||
|
||||
+16
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
||||
) : ISelectionRepo {
|
||||
|
||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||
private val currentQuery = MutableStateFlow("")
|
||||
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||
val ids = localSource.getIdsWithModes(list)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
itemsWithModes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
}
|
||||
override fun getModesList() = Sources.satelliteModes
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
||||
return@withContext itemsWithQuery
|
||||
}
|
||||
|
||||
override suspend fun setTypes(types: List<String>) {
|
||||
currentTypes.value = types
|
||||
settingsRepo.setSelectedTypes(types)
|
||||
override suspend fun setModes(modes: List<String>) {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
}
|
||||
|
||||
override suspend fun setQuery(query: String) {
|
||||
|
||||
+134
-38
@@ -29,16 +29,21 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
|
||||
class SettingsRepo(
|
||||
private val context: android.content.Context,
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String
|
||||
@@ -67,15 +72,17 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyHiddenScreens = "hiddenScreens"
|
||||
private val keyScreenOrder = "screenOrder"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -91,13 +98,23 @@ class SettingsRepo(
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySubMenuOrder = "subMenuOrder"
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogApiKey = "wavelogApiKey"
|
||||
private val keyWavelogStationId = "wavelogStationId"
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keyCwToneShiftEnabled = "cwToneShiftEnabled"
|
||||
private val keyAmsatDayStripes = "amsatDayStripes"
|
||||
|
||||
private val separatorComma = ","
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -105,10 +122,10 @@ class SettingsRepo(
|
||||
_satelliteSelection.value = ids
|
||||
}
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) {
|
||||
val typesString = types.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||
_typesSelection.value = types
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
val modesString = modes.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||
_satelliteModeSelection.value = modes
|
||||
}
|
||||
|
||||
private fun getSelectedIds(): List<Int> {
|
||||
@@ -117,10 +134,10 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -135,7 +152,6 @@ class SettingsRepo(
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
@@ -146,16 +162,13 @@ class SettingsRepo(
|
||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||
return PassesSettings(
|
||||
showDeepSpace,
|
||||
hoursAhead,
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
selectedModes
|
||||
invertAosTimeWindow
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -172,29 +185,50 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
|
||||
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
|
||||
// Wrap an out-of-range longitude into -180..180. Subtracting 180 (the
|
||||
// previous behaviour) mapped 270 to +90 instead of -90, i.e. the wrong
|
||||
// hemisphere, and 360 to +180 instead of 0.
|
||||
val newLongitude = ((longitude + 180.0).mod(360.0)) - 180.0
|
||||
val locator = positionToQth(latitude, newLongitude) ?: return false
|
||||
setStationPosition(latitude, newLongitude, altitude, locator)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(): Boolean {
|
||||
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
|
||||
override suspend fun setStationPosition(): Boolean {
|
||||
// Permission gate: fail fast without location permission (no swallowed exceptions)
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
println("GPS: no fine location permission")
|
||||
return false
|
||||
}
|
||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||
try {
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||
val location = locationManager.getLastKnownLocation(providerDef)
|
||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||
println("Requesting location for $provider provider")
|
||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||
} else {
|
||||
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||
val signal = android.os.CancellationSignal()
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||
// 15 s timeout
|
||||
val timeout = handler.postDelayed({
|
||||
signal.cancel()
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}, 15_000L)
|
||||
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||
if (cont.isActive) cont.resume(true) { }
|
||||
}
|
||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||
val provider = if (hasGps) providerGps else providerNet
|
||||
try {
|
||||
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||
} catch (exception: SecurityException) {
|
||||
println("No permissions were given - $exception")
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
}
|
||||
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean {
|
||||
@@ -235,6 +269,7 @@ class SettingsRepo(
|
||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
@@ -298,6 +333,10 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -307,6 +346,7 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -315,7 +355,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -327,6 +367,8 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -356,6 +398,18 @@ class SettingsRepo(
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
||||
putString(keyWavelogUrl, new.wavelogUrl)
|
||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||
putString(keyWavelogStationId, new.wavelogStationId)
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
|
||||
putBoolean(keyAmsatDayStripes, new.amsatDayStripes)
|
||||
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -371,8 +425,19 @@ class SettingsRepo(
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f),
|
||||
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false),
|
||||
amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true)
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -390,12 +455,21 @@ class SettingsRepo(
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
|
||||
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -435,5 +509,27 @@ class SettingsRepo(
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -37,6 +37,8 @@ class RemoteSource(
|
||||
try {
|
||||
val fileUri = uri.toUri()
|
||||
contentResolver.openInputStream(fileUri)?.buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource file stream exception: $exception")
|
||||
null
|
||||
@@ -46,13 +48,75 @@ class RemoteSource(
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
ByteArrayInputStream(response.body.bytes())
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat catalog exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat reports exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat summary exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
|
||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||
}
|
||||
} finally {
|
||||
recorder.stop()
|
||||
recorder.release()
|
||||
// stop() on a recorder that never started throws
|
||||
// IllegalStateException; wrapping each cleanup step separately
|
||||
// keeps the original error (e.g. a permission denial during
|
||||
// startRecording) intact and guarantees release() still runs.
|
||||
// Without this, a start failure masked the real cause AND leaked
|
||||
// the recorder because release() was skipped.
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
|
||||
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
|
||||
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
|
||||
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.wavelog
|
||||
|
||||
import android.content.Context
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.URL
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
|
||||
|
||||
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
|
||||
|
||||
/** Restore the last downloaded list from SharedPreferences (call at process start) */
|
||||
override fun restore() {
|
||||
val saved = prefs.getString(KEY_NAMES, null)
|
||||
if (!saved.isNullOrBlank()) {
|
||||
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
|
||||
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
|
||||
val conn = url.openConnection()
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 20_000
|
||||
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
|
||||
val raw = conn.getInputStream()
|
||||
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
|
||||
val text = reader.use { it.readText() }
|
||||
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
|
||||
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
|
||||
.map { it.groupValues[1].uppercase() }
|
||||
.toSet()
|
||||
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
|
||||
LotwSatellites.updateNames(names)
|
||||
prefs.edit()
|
||||
.putString(KEY_NAMES, names.joinToString(","))
|
||||
.putLong(KEY_UPDATED, System.currentTimeMillis())
|
||||
.apply()
|
||||
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
|
||||
} catch (e: Exception) {
|
||||
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_NAMES = "names"
|
||||
private const val KEY_UPDATED = "updated"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* The gating contract the decoder relies on: shift only when the user opted in AND the
|
||||
* tone is outside the model window.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed
|
||||
* here. What these tests do exercise is the real decision function the decoder calls -
|
||||
* [CwToneShifter.analyse] - rather than a copy of it, so a wrong verdict fails here.
|
||||
* The decoder's own sample accumulation and throttling are covered by the streaming
|
||||
* tests in core:domain.
|
||||
*/
|
||||
class CwToneShiftGateTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
private fun tone(hz: Double, samples: Int = 1600): FloatArray = FloatArray(samples) { i ->
|
||||
sin(2.0 * PI * hz * i / sampleRate).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled/disabled gate as [CwDeepDecoder.applyToneShift] applies it: when off
|
||||
* the audio is returned as-is, when on the verdict comes from the real analyser.
|
||||
*/
|
||||
private fun gate(audio: FloatArray, enabled: Boolean): FloatArray {
|
||||
if (!enabled) return audio
|
||||
val analysis = CwToneShifter.analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio
|
||||
return CwToneShifter.shift(audio, analysis.shiftHz, sampleRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled leaves every tone untouched`() {
|
||||
for (hz in listOf(150.0, 300.0, 800.0, 1200.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz must pass through unchanged while the setting is off",
|
||||
audio, gate(audio, enabled = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled still leaves in-window tones untouched`() {
|
||||
for (hz in listOf(400.0, 600.0, 800.0, 1000.0, 1200.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz is inside the window; enabling the setting must not alter it",
|
||||
audio, gate(audio, enabled = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled shifts only out-of-window tones`() {
|
||||
for (hz in listOf(200.0, 300.0, 1300.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
val result = gate(audio, enabled = true)
|
||||
assertFalse("$hz Hz should have been shifted", result === audio)
|
||||
assertEquals("shift must preserve length", audio.size, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window edges count as inside`() {
|
||||
val analysisLow = CwToneShifter.analyse(tone(CwDeepSpectrogram.MIN_FREQ_HZ), sampleRate)
|
||||
val analysisHigh = CwToneShifter.analyse(tone(CwDeepSpectrogram.MAX_FREQ_HZ), sampleRate)
|
||||
assertFalse("400 Hz is the lower edge, inside", analysisLow.needsShift)
|
||||
assertFalse("1200 Hz is the upper edge, inside", analysisHigh.needsShift)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift target is inside the window`() {
|
||||
assertTrue(
|
||||
"the target must be a pitch the model can see",
|
||||
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the defect that made the whole feature dead on arrival:
|
||||
* the decoder gated detection on a single chunk reaching DETECT_MIN_SAMPLES, but
|
||||
* AudioCapture delivers 4410 samples at 44.1 kHz, which is only 320 after
|
||||
* resampling to 3200 Hz. Detection could never run.
|
||||
*
|
||||
* The decoder now pools chunks, so what matters is that the pooled size is
|
||||
* reachable: a handful of real-sized chunks must add up to enough audio.
|
||||
*/
|
||||
@Test
|
||||
fun `pooled capture chunks reach the detection threshold`() {
|
||||
val captureRate = 44100
|
||||
val captureChunk = captureRate / 10 // AudioCapture's ~100 ms read
|
||||
val resampledChunk = captureChunk * CwDeepSpectrogram.SAMPLE_RATE / captureRate
|
||||
assertEquals(
|
||||
"a capture chunk resamples to 320 samples; if this changes revisit pooling",
|
||||
320, resampledChunk
|
||||
)
|
||||
|
||||
val threshold = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES
|
||||
val chunksNeeded = (threshold + resampledChunk - 1) / resampledChunk
|
||||
assertTrue(
|
||||
"a single chunk ($resampledChunk) must not be expected to reach $threshold",
|
||||
resampledChunk < threshold
|
||||
)
|
||||
assertTrue(
|
||||
"pooling must reach the threshold within a second of audio, needs $chunksNeeded chunks",
|
||||
chunksNeeded in 2..10
|
||||
)
|
||||
|
||||
// And that much audio must actually be enough for the detector to work.
|
||||
val pooled = tone(1500.0, samples = threshold)
|
||||
val detected = CwToneShifter.detectToneHz(pooled, sampleRate)
|
||||
assertEquals(
|
||||
"the pooled window must be long enough to detect a tone",
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* ADVERSARIAL AUDIT SCRATCH FILE - delete when the audit report is written.
|
||||
* Probes buildStatuses for aliasing, midnight arithmetic and boundary defects.
|
||||
*/
|
||||
class AmSatAuditTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
|
||||
private fun utc(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0, s: Int = 0): Long {
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c.clear(); c.set(y, mo - 1, d, h, mi, s)
|
||||
return c.timeInMillis / 1000
|
||||
}
|
||||
|
||||
private fun rep(name: String, at: Long, id: String, status: String = "heard") =
|
||||
ApiReport(id, name, "T", status, "AA00", at)
|
||||
|
||||
private fun labelsAt(now: Long) =
|
||||
repo.buildStatuses(listOf("X"), emptyList(), now).single().days.map { it.dateLabel }
|
||||
|
||||
/** Reference: the label a UTC instant's day should carry. */
|
||||
private fun expectLabel(y: Int, mo: Int, d: Int): String {
|
||||
val mn = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec")
|
||||
return "${mn[mo - 1]} $d"
|
||||
}
|
||||
|
||||
// ---------- 1. midnight arithmetic under hostile inputs ----------
|
||||
|
||||
@Test
|
||||
fun auditMidnightExactlyAtMidnight() {
|
||||
assertEquals(
|
||||
listOf(expectLabel(2026, 8, 22), expectLabel(2026, 8, 21), expectLabel(2026, 8, 20)),
|
||||
labelsAt(utc(2026, 8, 22, 0, 0, 0))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditMidnightOneSecondBeforeAndAfter() {
|
||||
assertEquals(
|
||||
"23:59:59 on Aug 21 must still be Aug 21",
|
||||
listOf("Aug 21", "Aug 20", "Aug 19"),
|
||||
labelsAt(utc(2026, 8, 21, 23, 59, 59))
|
||||
)
|
||||
assertEquals(
|
||||
"00:00:01 on Aug 22 must already be Aug 22",
|
||||
listOf("Aug 22", "Aug 21", "Aug 20"),
|
||||
labelsAt(utc(2026, 8, 22, 0, 0, 1))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditLeapDay2028() {
|
||||
assertEquals(
|
||||
"Feb 29 2028 back to Feb 27",
|
||||
listOf("Feb 29", "Feb 28", "Feb 27"),
|
||||
labelsAt(utc(2028, 2, 29, 12))
|
||||
)
|
||||
assertEquals(
|
||||
"Mar 1 2028 must reach back through the leap day",
|
||||
listOf("Mar 1", "Feb 29", "Feb 28"),
|
||||
labelsAt(utc(2028, 3, 1, 0, 0, 0))
|
||||
)
|
||||
assertEquals(
|
||||
"Mar 1 2027 (no leap day) must skip straight to Feb 27",
|
||||
listOf("Mar 1", "Feb 28", "Feb 27"),
|
||||
labelsAt(utc(2027, 3, 1, 12))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditYearBoundary() {
|
||||
assertEquals(
|
||||
listOf("Jan 1", "Dec 31", "Dec 30"),
|
||||
labelsAt(utc(2027, 1, 1, 0, 0, 0))
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Jan 2", "Jan 1", "Dec 31"),
|
||||
labelsAt(utc(2027, 1, 2, 23, 59, 59))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditMonthBoundariesEveryMonth() {
|
||||
// First of every month in a leap and a non-leap year.
|
||||
for (year in listOf(2027, 2028)) {
|
||||
for (mo in 1..12) {
|
||||
val now = utc(year, mo, 1, 0, 0, 0)
|
||||
val got = labelsAt(now)
|
||||
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||
ref.timeInMillis = now * 1000
|
||||
val want = (0 until 3).map {
|
||||
val c = ref.clone() as Calendar
|
||||
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
}
|
||||
assertEquals("$year-$mo-01", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The load-bearing claim: subtracting 86400 equals Calendar day arithmetic in UTC.
|
||||
* Proven exhaustively over 20 years of days rather than argued.
|
||||
*/
|
||||
@Test
|
||||
fun auditSubtracting86400EqualsCalendarDayArithmeticForTwentyYears() {
|
||||
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||
var now = utc(2020, 1, 1, 12)
|
||||
val end = utc(2040, 1, 1, 12)
|
||||
var checked = 0
|
||||
while (now < end) {
|
||||
val got = labelsAt(now)
|
||||
ref.timeInMillis = now * 1000
|
||||
val want = (0 until 3).map {
|
||||
val c = ref.clone() as Calendar
|
||||
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
}
|
||||
assertEquals("at epoch $now", want, got)
|
||||
now += 86400
|
||||
checked++
|
||||
}
|
||||
assertTrue("must have checked >7000 days, got $checked", checked > 7000)
|
||||
}
|
||||
|
||||
/**
|
||||
* The device default zone must not reach the computation. Run the whole build under
|
||||
* hostile default zones including ones with DST and half-hour offsets, and under the
|
||||
* DST transition instants of those zones.
|
||||
*/
|
||||
@Test
|
||||
fun auditDefaultTimeZoneCannotInfluenceTheGrid() {
|
||||
val original = TimeZone.getDefault()
|
||||
try {
|
||||
val zones = listOf(
|
||||
"UTC", "America/New_York", "Europe/Berlin", "Australia/Lord_Howe",
|
||||
"Asia/Kolkata", "Pacific/Kiritimati", "Pacific/Niue", "Pacific/Chatham",
|
||||
"America/Sao_Paulo", "Asia/Kathmandu"
|
||||
)
|
||||
// Instants that are DST transitions in at least one zone above.
|
||||
val instants = listOf(
|
||||
utc(2026, 3, 8, 7), utc(2026, 11, 1, 6), utc(2026, 3, 29, 1),
|
||||
utc(2026, 10, 25, 1), utc(2026, 4, 5, 16), utc(2026, 10, 4, 16),
|
||||
utc(2026, 8, 22, 0, 0, 0), utc(2026, 8, 22, 23, 59, 59),
|
||||
utc(2027, 1, 1, 0, 0, 0), utc(2028, 2, 29, 0, 0, 0)
|
||||
)
|
||||
val baseline = HashMap<Long, List<String>>()
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
for (i in instants) baseline[i] = labelsAt(i)
|
||||
|
||||
for (z in zones) {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone(z))
|
||||
for (i in instants) {
|
||||
assertEquals("zone $z at $i", baseline[i], labelsAt(i))
|
||||
// and the placement of a report must not move either
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", i - 3600, "r")), i
|
||||
).single()
|
||||
val cell = s.days.withIndex().flatMap { (d, day) ->
|
||||
day.slots.withIndex().filter { "r" in it.value.reportIds }
|
||||
.map { d to it.index }
|
||||
}
|
||||
assertEquals("zone $z placement at $i", 1, cell.size)
|
||||
baseline["p$i".hashCode().toLong()]?.let { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TimeZone.setDefault(original)
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale can swap the calendar system out from under Calendar.getInstance. */
|
||||
@Test
|
||||
fun auditDefaultLocaleCannotInfluenceTheGrid() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
val want = run {
|
||||
Locale.setDefault(Locale.US)
|
||||
labelsAt(utc(2026, 8, 22, 12))
|
||||
}
|
||||
for (l in listOf(
|
||||
Locale("th", "TH", "TH"), Locale("ja", "JP", "JP"),
|
||||
Locale("ar", "SA"), Locale.forLanguageTag("th-TH-u-ca-buddhist")
|
||||
)) {
|
||||
Locale.setDefault(l)
|
||||
assertEquals("locale $l", want, labelsAt(utc(2026, 8, 22, 12)))
|
||||
}
|
||||
} finally {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- aliasing / shared Calendar state leak ----------
|
||||
|
||||
/**
|
||||
* The shared Calendar is mutated by the labels loop after todayMidnightSec is read.
|
||||
* If any later step re-read it, day 0 would inherit day 2's date. Prove day 0's
|
||||
* slots are anchored on today, not on the last value the Calendar held.
|
||||
*/
|
||||
@Test
|
||||
fun auditSharedCalendarIsNotReReadAfterTheLabelsLoop() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
// A report at today 12:30 must be in day 0. If the anchor had leaked to Aug 20
|
||||
// it would fall outside the grid entirely.
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 12, 30), "r")), now
|
||||
).single()
|
||||
assertEquals("Aug 22", s.days[0].dateLabel)
|
||||
assertTrue("today's report must be in day 0 slot 5", "r" in s.days[0].slots[5].reportIds)
|
||||
assertTrue(
|
||||
"no other day may hold it",
|
||||
s.days.drop(1).all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
/** Two consecutive calls on the same repository must be identical (no instance state). */
|
||||
@Test
|
||||
fun auditRepeatedCallsAreIdempotent() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val reports = listOf(
|
||||
rep("X", utc(2026, 8, 22, 1), "a"), rep("X", utc(2026, 8, 21, 23), "b"),
|
||||
rep("X", utc(2026, 8, 20, 0, 0, 0), "c")
|
||||
)
|
||||
fun shape() = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||
.days.map { d -> d.dateLabel to d.slots.map { it.reportIds } }
|
||||
val first = shape()
|
||||
repeat(5) { assertEquals("call must not drift", first, shape()) }
|
||||
}
|
||||
|
||||
// ---------- slot boundary exactness ----------
|
||||
|
||||
/** No report may appear in two cells, and none inside the window may vanish. */
|
||||
@Test
|
||||
fun auditEveryBoundaryInstantLandsInExactlyOneCell() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
// every slot edge of all three days, and one second either side of each
|
||||
val probes = ArrayList<Long>()
|
||||
for (d in 0 until 3) for (s in 0..12) {
|
||||
val edge = mid - d * 86400L + s * 7200L
|
||||
probes.add(edge - 1); probes.add(edge); probes.add(edge + 1)
|
||||
}
|
||||
for (t in probes.distinct()) {
|
||||
val s = repo.buildStatuses(listOf("X"), listOf(rep("X", t, "r")), now).single()
|
||||
val hits = s.days.withIndex().flatMap { (di, day) ->
|
||||
day.slots.withIndex().filter { "r" in it.value.reportIds }.map { di to it.index }
|
||||
}
|
||||
val inWindow = t >= mid - 2 * 86400L && t < mid + 86400L
|
||||
if (inWindow) {
|
||||
assertEquals("epoch $t must occupy exactly one cell, got $hits", 1, hits.size)
|
||||
// and the cell's day must match the report's UTC date
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c.timeInMillis = t * 1000
|
||||
val want = expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
assertEquals("epoch $t day label", want, s.days[hits[0].first].dateLabel)
|
||||
// slot index must invert the hour band
|
||||
assertEquals("epoch $t slot", 11 - c.get(Calendar.HOUR_OF_DAY) / 2, hits[0].second)
|
||||
} else {
|
||||
assertEquals("epoch $t is outside the window", 0, hits.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts must sum to the number of in-window reports: nothing dropped, nothing doubled. */
|
||||
@Test
|
||||
fun auditCountsConserveReports() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
val reports = ArrayList<ApiReport>()
|
||||
var i = 0
|
||||
var t = mid - 2 * 86400L
|
||||
while (t < mid + 86400L) { reports.add(rep("X", t, "r${i++}")); t += 1801 }
|
||||
val s = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||
val total = s.days.sumOf { d -> d.slots.sumOf { it.count } }
|
||||
val ids = s.days.flatMap { d -> d.slots.flatMap { it.reportIds } }
|
||||
assertEquals("every in-window report must be counted once", reports.size, total)
|
||||
assertEquals("no id may repeat", ids.size, ids.toSet().size)
|
||||
assertEquals("id set must be complete", reports.map { it.id }.toSet(), ids.toSet())
|
||||
}
|
||||
|
||||
// ---------- duplicate catalogue names ----------
|
||||
|
||||
@Test
|
||||
fun auditDuplicateCatalogueNamesProduceDuplicateRows() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("DUP", "DUP", "OTHER"),
|
||||
listOf(rep("DUP", utc(2026, 8, 22, 11), "r")),
|
||||
utc(2026, 8, 22, 12)
|
||||
)
|
||||
assertEquals("a duplicated catalogue name yields a duplicated row", 3, s.size)
|
||||
assertEquals(2, s.count { it.name == "DUP" })
|
||||
// both duplicated rows carry the same report -> the tap dialog double lists it
|
||||
assertEquals(
|
||||
listOf(1, 1),
|
||||
s.filter { it.name == "DUP" }.map { it.days[0].slots[6].count }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditReportsForNamesAbsentFromCatalogueAreSilentlyDropped() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("IN-CATALOG"),
|
||||
listOf(rep("NOT-IN-CATALOG", utc(2026, 8, 22, 11), "ghost")),
|
||||
utc(2026, 8, 22, 12)
|
||||
)
|
||||
assertTrue(
|
||||
"a report whose satellite is not in the catalogue never renders",
|
||||
s.single().days.all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- unparsable timestamps ----------
|
||||
|
||||
@Test
|
||||
fun auditZeroTimestampFromFailedParseIsDroppedNotShownAsEpoch() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", 0L, "unparsable")), utc(2026, 8, 22, 12)
|
||||
).single()
|
||||
assertTrue(
|
||||
"a 0L timestamp (parse failure) must not render",
|
||||
s.days.all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- future reports ----------
|
||||
|
||||
@Test
|
||||
fun auditFutureReportsLaterTodayStillRender() {
|
||||
// Fetched at 07:00; a report stamped 23:00 today lands in slot 0 of today.
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 23), "later")), utc(2026, 8, 22, 7)
|
||||
).single()
|
||||
assertTrue("today's later bands are pre-drawn", "later" in s.days[0].slots[0].reportIds)
|
||||
}
|
||||
|
||||
// ---------- complexity ----------
|
||||
|
||||
/** One pass per slot over the satellite's own reports; not O(all reports x slots). */
|
||||
@Test
|
||||
fun auditBuildIsLinearInReportsNotQuadratic() {
|
||||
fun timeFor(nSats: Int, nReports: Int): Long {
|
||||
val names = (0 until nSats).map { "S$it" }
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
val reports = (0 until nReports).map {
|
||||
rep(names[it % nSats], mid - (it % 172800).toLong(), "r$it")
|
||||
}
|
||||
repo.buildStatuses(names, reports, now) // warm
|
||||
val t0 = System.nanoTime()
|
||||
repeat(3) { repo.buildStatuses(names, reports, now) }
|
||||
return System.nanoTime() - t0
|
||||
}
|
||||
val small = timeFor(88, 500)
|
||||
val big = timeFor(88, 5000)
|
||||
val ratio = big.toDouble() / small
|
||||
println("AUDIT complexity: 500 reports=${small / 1_000_000}ms 5000=${big / 1_000_000}ms ratio=$ratio")
|
||||
assertNotNull(ratio)
|
||||
assertTrue("10x the reports must not cost >40x the time (ratio=$ratio)", ratio < 40)
|
||||
}
|
||||
|
||||
/** toSatReport's YEAR is locale sensitive: proves whether the dialog date corrupts. */
|
||||
@Test
|
||||
fun auditReportDialogDateUnderThaiLocale() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
Locale.setDefault(Locale.US)
|
||||
c.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||
val gregorianYear = c.get(Calendar.YEAR)
|
||||
Locale.setDefault(Locale("th", "TH", "TH"))
|
||||
val c2 = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c2.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||
val thaiYear = c2.get(Calendar.YEAR)
|
||||
println("AUDIT locale year: gregorian=$gregorianYear thai=$thaiYear class=${c2.javaClass.name}")
|
||||
assertEquals(
|
||||
"if these differ, toSatReport prints a Buddhist year in the dialog",
|
||||
gregorianYear, thaiYear
|
||||
)
|
||||
} finally {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Pins the grid the AMSAT status page draws.
|
||||
*
|
||||
* Two contracts matter. The day cell renders one stripe per slot, so "every day has
|
||||
* exactly 12 slots, newest first" became load-bearing. And the day columns are UTC
|
||||
* calendar days, so a report must land in the cell whose label matches its UTC date - an
|
||||
* earlier rolling window anchored on "now" put 17.9 hours of yesterday into the cell
|
||||
* labelled today, and 73% of a live 1021-report page landed in the wrong column.
|
||||
*
|
||||
* This drives [AmSatRepository.buildStatuses] directly rather than `fetchStatus`, because
|
||||
* the parsing around it uses Android's `JSONObject`, a stub on the JVM: a `fetchStatus`
|
||||
* test returns null for every input and proves nothing.
|
||||
*/
|
||||
class AmSatSlotBuildTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
|
||||
/** Epoch seconds for a UTC wall-clock instant, so every case reads unambiguously. */
|
||||
private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.clear()
|
||||
cal.set(year, month - 1, day, hour, minute, 0)
|
||||
return cal.timeInMillis / 1000
|
||||
}
|
||||
|
||||
/** Midday, so "today" has hours on both sides of the fetch. */
|
||||
private val nowSec = utc(2026, 8, 22, 12)
|
||||
|
||||
private fun report(name: String, status: String, at: Long, id: String = "r-$name-$at") =
|
||||
ApiReport(
|
||||
id = id,
|
||||
name = name,
|
||||
callsign = "TEST",
|
||||
report = status,
|
||||
gridSquare = "AA00",
|
||||
reportedTimeUtcSec = at
|
||||
)
|
||||
|
||||
private fun build(names: List<String>, reports: List<ApiReport>) =
|
||||
repo.buildStatuses(names, reports, nowSec)
|
||||
|
||||
@Test
|
||||
fun `every day carries exactly twelve slots`() {
|
||||
val statuses = build(
|
||||
listOf("AO-91", "SO-50", "ISS"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11)))
|
||||
)
|
||||
assertEquals(3, statuses.size)
|
||||
for (status in statuses) {
|
||||
assertEquals("${status.name} must have 3 days", 3, status.days.size)
|
||||
for (day in status.days) {
|
||||
assertEquals(
|
||||
"${status.name} ${day.dateLabel} must have 12 slots for the stripe renderer",
|
||||
12, day.slots.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a satellite nobody reported still gets twelve slots per day`() {
|
||||
// The renderer must never receive an empty list, which would draw nothing at all.
|
||||
val status = build(listOf("QUIET-1"), emptyList()).single()
|
||||
assertEquals(3, status.days.size)
|
||||
status.days.forEach { assertEquals(12, it.slots.size) }
|
||||
assertTrue(
|
||||
"a silent satellite must be all no-report slots",
|
||||
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days are labelled with UTC calendar dates`() {
|
||||
val status = build(listOf("AO-91"), emptyList()).single()
|
||||
assertEquals("today", "Aug 22", status.days[0].dateLabel)
|
||||
assertEquals("yesterday", "Aug 21", status.days[1].dateLabel)
|
||||
assertEquals("the day before", "Aug 20", status.days[2].dateLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label does not drift with the time of day`() {
|
||||
// The old rolling window relabelled the same data depending on when it was
|
||||
// fetched. A calendar day must not care.
|
||||
for (hour in listOf(0, 6, 12, 18, 23)) {
|
||||
val labels = repo.buildStatuses(listOf("AO-91"), emptyList(), utc(2026, 8, 22, hour))
|
||||
.single().days.map { it.dateLabel }
|
||||
assertEquals("fetched at ${hour}:00 UTC", listOf("Aug 22", "Aug 21", "Aug 20"), labels)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slots cover fixed UTC bands, newest first`() {
|
||||
// Slot 0 is 22:00-24:00 and slot 11 is 00:00-02:00, matching amsat.org.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 23), id = "lateToday"),
|
||||
report("AO-91", "not heard", utc(2026, 8, 22, 1), id = "earlyToday")
|
||||
)
|
||||
).single()
|
||||
val today = status.days[0]
|
||||
|
||||
assertTrue(
|
||||
"23:00 belongs in slot 0, the day's last band",
|
||||
"lateToday" in today.slots[0].reportIds
|
||||
)
|
||||
assertTrue(
|
||||
"01:00 belongs in slot 11, the day's first band",
|
||||
"earlyToday" in today.slots[11].reportIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report lands in the day matching its UTC date`() {
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 11), id = "today"),
|
||||
report("AO-91", "heard", utc(2026, 8, 21, 15), id = "yesterday"),
|
||||
report("AO-91", "heard", utc(2026, 8, 20, 5), id = "dayBefore")
|
||||
)
|
||||
).single()
|
||||
|
||||
// Positions computed from the UTC bands: 11:00 -> slot 6, 15:00 -> slot 4,
|
||||
// 05:00 -> slot 9.
|
||||
assertTrue("today's report", "today" in status.days[0].slots[6].reportIds)
|
||||
assertTrue("yesterday's report", "yesterday" in status.days[1].slots[4].reportIds)
|
||||
assertTrue("the day before", "dayBefore" in status.days[2].slots[9].reportIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report just after midnight stays in the new day`() {
|
||||
// The boundary the rolling window got wrong: 00:30 today must not appear as
|
||||
// yesterday.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 0, 30), id = "justAfterMidnight"))
|
||||
).single()
|
||||
|
||||
assertTrue(
|
||||
"00:30 belongs to today's first band",
|
||||
"justAfterMidnight" in status.days[0].slots[11].reportIds
|
||||
)
|
||||
assertTrue(
|
||||
"yesterday must stay empty",
|
||||
status.days[1].slots.all { it.count == 0 }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each status maps to its own colour`() {
|
||||
// The stripes are now the only carrier of status, so distinct states must stay
|
||||
// distinct all the way out of the repository.
|
||||
val at = utc(2026, 8, 22, 11)
|
||||
val statuses = build(
|
||||
listOf("A", "B", "C", "D"),
|
||||
listOf(
|
||||
report("A", "heard", at),
|
||||
report("B", "telemetry only", at),
|
||||
report("C", "not heard", at),
|
||||
report("D", "something the api invented", at)
|
||||
)
|
||||
)
|
||||
val colours = statuses.map { status -> status.days[0].slots[6].statusColor }
|
||||
assertTrue("no state may be colourless", colours.none { it == 0L })
|
||||
assertEquals(
|
||||
"heard, telemetry and not heard must be visually distinct",
|
||||
3, colours.take(3).toSet().size
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot keeps every report it contains`() {
|
||||
// The tap dialog lists reports from the slots, so none may be dropped when several
|
||||
// land in the same two-hour window. 10:00-12:00 is slot 6.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 10, 15), id = "a"),
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 11, 0), id = "b"),
|
||||
report("AO-91", "not heard", utc(2026, 8, 22, 11, 45), id = "c")
|
||||
)
|
||||
).single()
|
||||
val slot = status.days[0].slots[6]
|
||||
assertEquals("all three reports fall in the same band", 3, slot.count)
|
||||
assertEquals(setOf("a", "b", "c"), slot.reportIds.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot shows the newest status when reports disagree`() {
|
||||
// Within one band the most recent observation wins; anything else would keep
|
||||
// showing a failure after the satellite recovered.
|
||||
fun colourFor(firstStatus: String, secondStatus: String): Long = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", firstStatus, utc(2026, 8, 22, 10, 15), id = "older"),
|
||||
report("AO-91", secondStatus, utc(2026, 8, 22, 11, 45), id = "newer")
|
||||
)
|
||||
).single().days[0].slots[6].statusColor
|
||||
|
||||
assertTrue(
|
||||
"the slot colour must follow the newest report, not the first",
|
||||
colourFor("not heard", "heard") != colourFor("heard", "not heard")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports outside the three-day window are ignored`() {
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 18, 12), id = "tooOld"),
|
||||
report("AO-91", "heard", utc(2026, 8, 23, 12), id = "future")
|
||||
)
|
||||
).single()
|
||||
assertTrue(
|
||||
"nothing outside the window may appear",
|
||||
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports for other satellites do not leak between rows`() {
|
||||
val statuses = build(
|
||||
listOf("AO-91", "SO-50"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11), id = "onlyAo91"))
|
||||
)
|
||||
val ao91 = statuses.first { it.name == "AO-91" }
|
||||
val so50 = statuses.first { it.name == "SO-50" }
|
||||
|
||||
assertEquals("AO-91 has its report", 1, ao91.days[0].slots[6].count)
|
||||
assertTrue(
|
||||
"SO-50 must stay empty",
|
||||
so50.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty catalog yields no rows rather than a malformed grid`() {
|
||||
assertTrue(build(emptyList(), emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots older than the data we received must not claim nobody was listening.
|
||||
*
|
||||
* The API caps at 500 records however many hours are asked for. Measured live, a
|
||||
* 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours
|
||||
* of the third day had no data at all - 352 of 3168 cells were painting "nobody heard
|
||||
* it" over "we never looked".
|
||||
*/
|
||||
@Test
|
||||
fun `slots before the data starts are marked no-data, not no-report`() {
|
||||
// The only report is midday yesterday, so nothing older than that was covered.
|
||||
val oldestReport = utc(2026, 8, 21, 12)
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(report("AO-91", "heard", oldestReport, id = "only"))
|
||||
).single()
|
||||
|
||||
val noReport = 0xFFC0C0C0
|
||||
val noData = 0xFFE8E8E8
|
||||
|
||||
// The day before yesterday is entirely before the data begins.
|
||||
assertTrue(
|
||||
"every slot older than the data must read as no-data",
|
||||
status.days[2].slots.all { it.statusColor == noData }
|
||||
)
|
||||
|
||||
// Yesterday straddles it: bands after midday are covered, bands before are not.
|
||||
val yesterday = status.days[1]
|
||||
assertEquals("the report's own band", 1, yesterday.slots[5].count)
|
||||
assertTrue(
|
||||
"bands after the oldest report are covered, so silence there is real",
|
||||
yesterday.slots.take(6).all { it.statusColor != noData }
|
||||
)
|
||||
assertTrue(
|
||||
"the earliest band of yesterday is before any data",
|
||||
yesterday.slots[11].statusColor == noData
|
||||
)
|
||||
|
||||
// Today is entirely after the data starts, so its silence is genuine.
|
||||
assertTrue(
|
||||
"today's empty slots mean nobody reported",
|
||||
status.days[0].slots.all { it.statusColor == noReport }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverage is judged from all reports, not one satellite's`() {
|
||||
// A satellite nobody reported must not show as no-data for the whole grid: the
|
||||
// slots were covered, that satellite simply was not heard.
|
||||
val statuses = build(
|
||||
listOf("LOUD", "QUIET"),
|
||||
listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early"))
|
||||
)
|
||||
val quiet = statuses.first { it.name == "QUIET" }
|
||||
val noData = 0xFFE8E8E8
|
||||
|
||||
assertTrue(
|
||||
"coverage reaches back to the earliest report of any satellite",
|
||||
quiet.days.all { day -> day.slots.none { it.statusColor == noData } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A report whose timestamp failed to parse must not disable the distinction.
|
||||
*
|
||||
* parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the
|
||||
* minimum timestamp in the response - so one such record would put the coverage
|
||||
* boundary in 1970 and mark every slot as reported-on. Measured on a grid that should
|
||||
* have had 18 no-data cells, a single zero timestamp took it to none.
|
||||
*/
|
||||
@Test
|
||||
fun `a report with an unparseable timestamp does not disable the no-data marking`() {
|
||||
val noData = 0xFFE8E8E8
|
||||
val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real")
|
||||
val brokenTimestamp = ApiReport(
|
||||
id = "broken",
|
||||
name = "AO-91",
|
||||
callsign = "TEST",
|
||||
report = "heard",
|
||||
gridSquare = "AA00",
|
||||
reportedTimeUtcSec = 0L
|
||||
)
|
||||
|
||||
val withoutBroken = build(listOf("AO-91"), listOf(realReport))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
|
||||
assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0)
|
||||
assertEquals(
|
||||
"a zero timestamp must not change what counts as covered",
|
||||
withoutBroken, withBroken
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty response marks nothing as covered`() {
|
||||
// With no reports at all there is no evidence about any slot.
|
||||
val status = build(listOf("AO-91"), emptyList()).single()
|
||||
val noData = 0xFFE8E8E8
|
||||
assertTrue(
|
||||
"yesterday and earlier cannot be claimed as silent",
|
||||
status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } }
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -102,7 +102,8 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
@@ -124,6 +125,12 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -165,10 +172,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
@@ -176,7 +183,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -193,13 +200,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
@@ -222,6 +229,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)", false),
|
||||
SatItem(40967, "TIANGONG", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setModes(listOf("REMOVED_MODE"))
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(44444, "Zeta", false),
|
||||
SatItem(25544, "Alpha", false),
|
||||
SatItem(40967, "Beta", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setSelection(listOf(40967), true)
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||
}
|
||||
|
||||
private class FakeLocalSource(
|
||||
private val entries: List<SatItem>
|
||||
) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = entries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||
|
||||
override suspend fun deleteEntries() = Unit
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
selectedModes: List<String>
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(false, false, "", "")
|
||||
)
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
selectedSatModes.value = modes
|
||||
}
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
|
||||
override suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
}
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
|
||||
*/
|
||||
object AprsPacket {
|
||||
|
||||
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
|
||||
fun passcode(callsign: String): Int {
|
||||
val s = callsign.split("-")[0].uppercase() + "\u0000"
|
||||
var hash = 29666 // 0x73E2
|
||||
var i = 0
|
||||
while (i <= s.length - 2) {
|
||||
hash = hash xor (s[i].code * 256 + s[i + 1].code)
|
||||
i += 2
|
||||
}
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
/** Login line: user CALL-SSID pass XXXX vers XXXX */
|
||||
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||
val callSsid = formatCallSsid(callsign, ssid)
|
||||
return "user $callSsid pass $passcode vers $version"
|
||||
}
|
||||
|
||||
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||
if (ssid.isNullOrEmpty()) return callsign
|
||||
return "$callsign-$ssid"
|
||||
}
|
||||
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude extension /A=000000 (feet). The field is a fixed six-digit
|
||||
* decimal, so a negative altitude (below sea level, or a bad GPS fix) must
|
||||
* be clamped: "%06d" of -164 yields "/A=-00164", which is not a valid
|
||||
* extension and corrupts the rest of the comment field.
|
||||
*/
|
||||
fun formatAltitude(altitudeMeters: Double?): String {
|
||||
if (altitudeMeters == null) return ""
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return String.format(Locale.ROOT, "/A=%06d", feet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Speed/course extension /CCC/SSS (degrees/knots). Course wraps into
|
||||
* 0..359 and speed is clamped to three digits, because "%03d" of an
|
||||
* out-of-range value widens the field and breaks the fixed-width format.
|
||||
*/
|
||||
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||
if (speedMps == null || bearing == null) return ""
|
||||
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
|
||||
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APRS position encoding (reverse-ported from ab0oo Position.java).
|
||||
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
|
||||
*/
|
||||
class AprsPosition(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val symbolTable: Char,
|
||||
val symbolCode: Char,
|
||||
val positionAmbiguity: Int = 0
|
||||
) {
|
||||
|
||||
/** Uncompressed format (APRS-IS default reporting format) */
|
||||
fun toUncompressedString(): String {
|
||||
val lat = getDMS(latitude, true)
|
||||
val lon = getDMS(longitude, false)
|
||||
return "$lat$symbolTable$lon$symbolCode"
|
||||
}
|
||||
|
||||
/** Compressed format (base91, standard APRS algorithm) */
|
||||
fun toCompressedString(): String {
|
||||
val jRound = round((90.0 - latitude) * 380926.0).toLong()
|
||||
val j = jRound / 753571 + 33
|
||||
val j2 = jRound % 753571
|
||||
val j3 = j2 / 8281 + 33
|
||||
val j4 = j2 % 8281
|
||||
val i = (j4 % 91).toInt() + 33
|
||||
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
|
||||
val j5 = jRound2 / 753571 + 33
|
||||
val j6 = jRound2 % 753571
|
||||
val j7 = 33 + j6 / 8281
|
||||
val j8 = j6 % 8281
|
||||
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
|
||||
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
|
||||
j5.toInt().toChar() + j7.toInt().toChar() +
|
||||
((j8 / 91).toInt() + 33).toChar() +
|
||||
((j8 % 91).toInt() + 33).toChar() + symbolCode
|
||||
}
|
||||
|
||||
/** Single-axis DMS encoding (hundredths) */
|
||||
private fun getDMS(value: Double, isLat: Boolean): String {
|
||||
var iRound = round(value * 6000.0).toInt()
|
||||
if (iRound < 0) iRound = -iRound
|
||||
val degrees = iRound / 6000
|
||||
val minutes = (iRound / 100) % 60
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||
}
|
||||
return if (isLat) {
|
||||
val ns = if (value >= 0) 'N' else 'S'
|
||||
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Bayesian Morse timing decoder.
|
||||
* Replaces hard thresholds with probability-based decision making.
|
||||
*
|
||||
* Inspired by VE3NEA's CW Skimmer approach:
|
||||
* "Instead of making a hard decision at every input sample whether the signal
|
||||
* is present or not, compute the probability that the signal is present."
|
||||
*
|
||||
* Uses Gaussian probability density centered on expected durations:
|
||||
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
||||
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
||||
*/
|
||||
internal class CwBayesianDecoder {
|
||||
|
||||
// Morse timing parameters
|
||||
private var dotDurationMs = 60f // initial 20 WPM
|
||||
private var speedWpm = 20f
|
||||
|
||||
// Current symbol being accumulated
|
||||
private var currentSymbol = StringBuilder()
|
||||
private var textBuffer = StringBuilder()
|
||||
|
||||
// Recent dit lengths for speed estimation
|
||||
private val recentDits = mutableListOf<Float>()
|
||||
|
||||
// Output
|
||||
private var _decodedText = ""
|
||||
val decodedText: String get() = _decodedText
|
||||
|
||||
/** Gaussian probability. */
|
||||
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||
if (varianceMs <= 0f) return 0f
|
||||
val diff = durationMs - expectedMs
|
||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||
}
|
||||
|
||||
/** Process a tone duration. Returns the symbol type with highest probability. */
|
||||
fun processTone(durationMs: Float): ToneResult {
|
||||
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
||||
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
|
||||
return if (ditProb > dashProb && ditProb > 0.05f) {
|
||||
currentSymbol.append('0')
|
||||
recentDits.add(durationMs)
|
||||
updateSpeed()
|
||||
ToneResult('0', ditProb)
|
||||
} else if (dashProb > 0.05f) {
|
||||
currentSymbol.append('1')
|
||||
ToneResult('1', dashProb)
|
||||
} else {
|
||||
ToneResult(null, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a gap duration. Returns decoded character or null. */
|
||||
fun processGap(durationMs: Float): Char? {
|
||||
if (currentSymbol.isEmpty()) {
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
if (wordProb > 0.2f) {
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return ' '
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
|
||||
if (wordProb > interCharProb && wordProb > 0.2f) {
|
||||
val char = flushSymbol()
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
if (interCharProb > 0.15f) {
|
||||
val char = flushSymbol()
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun flushSymbol(): Char? {
|
||||
if (currentSymbol.isEmpty()) return null
|
||||
val morse = currentSymbol.toString()
|
||||
currentSymbol.clear()
|
||||
val char = morseToChar(morse)
|
||||
if (char != null) textBuffer.append(char)
|
||||
return char
|
||||
}
|
||||
|
||||
private fun updateSpeed() {
|
||||
if (recentDits.size < 3) return
|
||||
val sorted = recentDits.sorted()
|
||||
val median = sorted[sorted.size / 2]
|
||||
if (median > 0f) {
|
||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||
if (wpm in 5f..55f) speedWpm = wpm
|
||||
}
|
||||
}
|
||||
|
||||
fun getSpeed(): Float = speedWpm
|
||||
|
||||
fun reset() {
|
||||
dotDurationMs = 60f
|
||||
speedWpm = 20f
|
||||
recentDits.clear()
|
||||
currentSymbol.clear()
|
||||
textBuffer.clear()
|
||||
_decodedText = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MORSE_TABLE = mapOf(
|
||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||
"0001001" to '$', "011010" to '@'
|
||||
)
|
||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||
}
|
||||
}
|
||||
|
||||
data class ToneResult(val symbol: Char?, val probability: Float)
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Multi-channel CW signal tracker.
|
||||
* Monitors the spectrogram for active frequency bins and extracts
|
||||
* energy envelopes for each detected signal.
|
||||
*
|
||||
* Inspired by CW Skimmer's multi-channel approach:
|
||||
* tracks all active signals in the passband simultaneously,
|
||||
* selects the best one for decoded output.
|
||||
*/
|
||||
internal class CwChannelTracker(
|
||||
private val spectrogram: CwSpectrogram,
|
||||
private val maxChannels: Int = 3
|
||||
) {
|
||||
data class Channel(
|
||||
val bin: Int,
|
||||
val frequency: Float,
|
||||
var active: Boolean = false,
|
||||
var energy: Float = 0f,
|
||||
val history: MutableList<Float> = mutableListOf(),
|
||||
var confidence: Float = 0f
|
||||
)
|
||||
|
||||
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
||||
|
||||
/** Scan the current spectrogram column and update channel tracking. */
|
||||
fun update(): List<Channel> {
|
||||
val col = spectrogram.getCurrentColumn()
|
||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||
|
||||
// Update existing channels
|
||||
for (ch in channels) {
|
||||
if (ch.active) {
|
||||
if (peaks.contains(ch.bin)) {
|
||||
ch.energy = col[ch.bin]
|
||||
ch.history.add(ch.energy)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence = computeConfidence(ch.history)
|
||||
} else {
|
||||
// Signal lost — decay confidence
|
||||
ch.history.add(0f)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence *= 0.9f
|
||||
if (ch.confidence < 0.1f) ch.active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign new peaks to inactive channels
|
||||
var peakIdx = 0
|
||||
for (ch in channels) {
|
||||
if (!ch.active && peakIdx < peaks.size) {
|
||||
val bin = peaks[peakIdx]
|
||||
val freq = spectrogram.binToFreq(bin)
|
||||
// Re-initialize channel
|
||||
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
||||
peakIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return channels.filter { it.active }
|
||||
}
|
||||
|
||||
/** Find peak bins in the spectrum. */
|
||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||
val peaks = mutableListOf<Int>()
|
||||
for (i in 1 until spectrum.size - 1) {
|
||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||
peaks.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return peaks.sortedByDescending { spectrum[it] }
|
||||
}
|
||||
|
||||
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
||||
private fun computeConfidence(history: List<Float>): Float {
|
||||
if (history.size < 10) return 0.3f
|
||||
val recent = history.takeLast(10)
|
||||
val mean = recent.average().toFloat()
|
||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/** Get the channel with highest confidence. */
|
||||
fun getBestChannel(): Channel? {
|
||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (i in channels.indices) {
|
||||
channels[i] = Channel(0, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Turns the model's `log_probs` output into text.
|
||||
*
|
||||
* Mirrors the reference `greedy_ctc_decode`: take the best class per frame,
|
||||
* drop blanks, and collapse runs of the same label. A blank between two
|
||||
* identical labels is what keeps a genuine double letter (for example the
|
||||
* two N's in "5NN") from collapsing into one.
|
||||
*/
|
||||
object CwCtcDecoder {
|
||||
|
||||
/**
|
||||
* @param logProbs `[batch, time, class]`; only batch 0 is read.
|
||||
* @param chars class index to symbol, excluding the blank.
|
||||
* @param blankIndex the CTC blank class (41 for this model).
|
||||
*/
|
||||
fun greedy(
|
||||
logProbs: Array<Array<FloatArray>>,
|
||||
chars: List<String>,
|
||||
blankIndex: Int
|
||||
): String {
|
||||
if (logProbs.isEmpty()) return ""
|
||||
val frames = logProbs[0]
|
||||
val builder = StringBuilder()
|
||||
var previous = -1
|
||||
|
||||
for (frame in frames) {
|
||||
var best = 0
|
||||
for (i in 1 until frame.size) {
|
||||
if (frame[i] > frame[best]) best = i
|
||||
}
|
||||
if (best == blankIndex) {
|
||||
previous = -1
|
||||
continue
|
||||
}
|
||||
if (best != previous && best < chars.size) {
|
||||
builder.append(chars[best])
|
||||
}
|
||||
previous = best
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
||||
*
|
||||
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
||||
* 1. FFT spectrogram creates a frequency×time matrix
|
||||
* 2. Multi-channel peak detector finds all active signals
|
||||
* 3. Per-channel energy envelope extraction
|
||||
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
||||
* 5. Best channel selected for output
|
||||
*
|
||||
* Timing analysis is performed per spectrogram column (hop).
|
||||
* Each column represents hopSize/sampleRate seconds of audio.
|
||||
*/
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000,
|
||||
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
||||
) {
|
||||
companion object {
|
||||
private const val FFT_SIZE = 256
|
||||
private const val HOP_SIZE = 64
|
||||
}
|
||||
|
||||
private val spectrogram = CwSpectrogram(
|
||||
fftSize = FFT_SIZE,
|
||||
hopSize = HOP_SIZE,
|
||||
sampleRate = sampleRate,
|
||||
minBin = 6,
|
||||
maxBin = 38,
|
||||
historyCols = 40
|
||||
)
|
||||
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
||||
private val bayesianDecoder = CwBayesianDecoder()
|
||||
|
||||
// Timing state per channel
|
||||
private data class ChannelTiming(
|
||||
var isSignal: Boolean = false,
|
||||
var toneTicks: Int = 0,
|
||||
var gapTicks: Int = 0
|
||||
)
|
||||
private val timingStates = Array(3) { ChannelTiming() }
|
||||
|
||||
// Time per spectrogram column in milliseconds
|
||||
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
||||
|
||||
// Output flows
|
||||
private val _decodedTextFlow = MutableStateFlow("")
|
||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
val signalStrength: StateFlow<Float> = _signalStrength
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
private var frameCount = 0
|
||||
|
||||
init {
|
||||
if (cwToneFreq > 0f) {
|
||||
_estimatedPitch.value = cwToneFreq
|
||||
}
|
||||
}
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Feed samples to spectrogram
|
||||
spectrogram.addSamples(buffer)
|
||||
|
||||
// 2. Get number of new columns generated
|
||||
val newCols = spectrogram.getNewColumns()
|
||||
if (newCols == 0) return
|
||||
|
||||
// 3. Update channel tracker (uses latest column for peak detection)
|
||||
val activeChannels = channelTracker.update()
|
||||
|
||||
// 4. Process each new column for timing analysis
|
||||
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
||||
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
||||
for (colOffset in 0 until newCols) {
|
||||
val col = spectrogram.getColumn(baseIdx + colOffset)
|
||||
|
||||
for ((idx, channel) in activeChannels.withIndex()) {
|
||||
if (idx >= timingStates.size) break
|
||||
val state = timingStates[idx]
|
||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||
|
||||
// Adaptive threshold
|
||||
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
||||
|
||||
if (energy > threshold) {
|
||||
if (!state.isSignal) {
|
||||
if (state.gapTicks > 0) {
|
||||
val gapMs = state.gapTicks * tickMs
|
||||
bayesianDecoder.processGap(gapMs)
|
||||
}
|
||||
state.gapTicks = 0
|
||||
state.isSignal = true
|
||||
}
|
||||
state.toneTicks++
|
||||
} else {
|
||||
if (state.isSignal) {
|
||||
if (state.toneTicks > 0) {
|
||||
val toneMs = state.toneTicks * tickMs
|
||||
bayesianDecoder.processTone(toneMs)
|
||||
}
|
||||
state.toneTicks = 0
|
||||
state.isSignal = false
|
||||
}
|
||||
state.gapTicks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update outputs
|
||||
frameCount++
|
||||
if (frameCount % 5 == 0) {
|
||||
val bestChannel = channelTracker.getBestChannel()
|
||||
if (bestChannel != null) {
|
||||
_estimatedPitch.value = bestChannel.frequency
|
||||
_signalStrength.value = bestChannel.confidence
|
||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||
}
|
||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
spectrogram.reset()
|
||||
channelTracker.reset()
|
||||
bayesianDecoder.reset()
|
||||
for (state in timingStates) {
|
||||
state.isSignal = false
|
||||
state.toneTicks = 0
|
||||
state.gapTicks = 0
|
||||
}
|
||||
frameCount = 0
|
||||
_decodedTextFlow.value = ""
|
||||
_signalStrength.value = 0f
|
||||
_estimatedPitch.value = null
|
||||
_estimatedSpeed.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Bounded rolling audio buffer that drives periodic full re-decodes.
|
||||
*
|
||||
* DeepCW is a whole-segment CTC model. It rewrites earlier output as more
|
||||
* context arrives — measured on one clip, 6 of 11 progressively longer reads
|
||||
* revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only
|
||||
* the newest fragment therefore leaves those intermediate guesses on screen
|
||||
* forever; measured character error rate for sliding-window stitching ranged
|
||||
* from 67% to 294%, against 0% for decoding the whole segment at once.
|
||||
*
|
||||
* So we keep a fixed window, re-run the model over all of it every
|
||||
* [redecodeIntervalMs], and replace the displayed text outright.
|
||||
*
|
||||
* Defaults come from measurement: 20 s is the smallest window that reaches
|
||||
* 0.0% CER (16 s still errs at 5.9%), while inference cost grows
|
||||
* super-linearly — 60 s of audio needs roughly 13x longer to decode than
|
||||
* 20 s does, leaving too little real-time headroom.
|
||||
*/
|
||||
class CwDeepBuffer(
|
||||
sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE,
|
||||
maxSeconds: Double = DEFAULT_MAX_SECONDS,
|
||||
private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_MAX_SECONDS = 20.0
|
||||
const val DEFAULT_REDECODE_INTERVAL_MS = 1500
|
||||
}
|
||||
|
||||
/** Maximum number of samples retained. */
|
||||
val capacity: Int = (sampleRate * maxSeconds).toInt()
|
||||
|
||||
private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000
|
||||
private val ring = FloatArray(capacity)
|
||||
private var writeIndex = 0
|
||||
private var filled = 0
|
||||
private var sinceLastRedecode = 0
|
||||
|
||||
/**
|
||||
* Samples evicted from the ring once it is full. They are the audio that
|
||||
* has scrolled out of the 20 s window, and are handed off (via
|
||||
* [drainOverflow]) so the decoder can archive them into permanent history
|
||||
* instead of silently dropping the corresponding text. Pre-allocated to
|
||||
* [capacity]: overflow never exceeds one window before it is drained.
|
||||
*/
|
||||
private val overflow = FloatArray(capacity)
|
||||
private var overflowSize = 0
|
||||
|
||||
/** Samples currently buffered, never above [capacity]. */
|
||||
val size: Int get() = filled
|
||||
|
||||
/** Samples currently held in the overflow (awaiting archival). */
|
||||
val overflowCount: Int get() = overflowSize
|
||||
|
||||
/** True once there is enough audio for the spectrogram to yield a frame. */
|
||||
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
|
||||
|
||||
/**
|
||||
* Append captured audio, overwriting the oldest samples when full.
|
||||
*
|
||||
* @return true when [redecodeIntervalMs] of audio has accumulated since
|
||||
* the last time this returned true, meaning the caller should re-decode.
|
||||
*/
|
||||
fun append(chunk: FloatArray): Boolean {
|
||||
if (chunk.isNotEmpty()) {
|
||||
// A chunk longer than the window can only contribute its tail.
|
||||
val start = maxOf(0, chunk.size - capacity)
|
||||
for (i in start until chunk.size) {
|
||||
if (filled == capacity) {
|
||||
// The slot we are about to overwrite holds the oldest
|
||||
// sample — move it to the overflow for archival.
|
||||
overflow[overflowSize++] = ring[writeIndex]
|
||||
}
|
||||
ring[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
if (filled < capacity) filled++
|
||||
}
|
||||
}
|
||||
|
||||
sinceLastRedecode += chunk.size
|
||||
if (sinceLastRedecode >= samplesPerInterval) {
|
||||
sinceLastRedecode -= samplesPerInterval
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Buffered audio in chronological order, as a copy safe to hand off. */
|
||||
fun snapshot(): FloatArray {
|
||||
val out = FloatArray(filled)
|
||||
if (filled == 0) return out
|
||||
val start = (writeIndex - filled + capacity) % capacity
|
||||
val firstRun = minOf(filled, capacity - start)
|
||||
ring.copyInto(out, 0, start, start + firstRun)
|
||||
if (firstRun < filled) {
|
||||
ring.copyInto(out, firstRun, 0, filled - firstRun)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the evicted samples (chronological order) and clear the overflow.
|
||||
* Safe to call every append; returns an empty array when nothing has been
|
||||
* evicted yet.
|
||||
*/
|
||||
fun drainOverflow(): FloatArray {
|
||||
if (overflowSize == 0) return FloatArray(0)
|
||||
val out = overflow.copyOf(overflowSize)
|
||||
overflowSize = 0
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drop all audio (including pending overflow) and restart the interval. */
|
||||
fun reset() {
|
||||
writeIndex = 0
|
||||
filled = 0
|
||||
sinceLastRedecode = 0
|
||||
overflowSize = 0
|
||||
ring.fill(0f)
|
||||
overflow.fill(0f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.ln1p
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Audio front-end for the DeepCW model: turns PCM samples into the
|
||||
* `[time, frequency]` log-magnitude spectrogram the network expects.
|
||||
*
|
||||
* Mirrors the upstream Python reference (deepcw-engine
|
||||
* `examples/python/decode_morse.py`) step for step:
|
||||
*
|
||||
* resample -> 3200 Hz, reflect-pad by fft/2, periodic Hann window of 256,
|
||||
* real FFT, keep bins [32, 97) i.e. 400-1200 Hz, then log1p.
|
||||
*
|
||||
* The model's fixed 400-1200 Hz window means pitch detection is built in —
|
||||
* no spectral peak tracking or squelch gating is needed on our side.
|
||||
*/
|
||||
object CwDeepSpectrogram {
|
||||
|
||||
/** Model input sample rate, from `model.onnx.json`. */
|
||||
const val SAMPLE_RATE = 3200
|
||||
|
||||
/** FFT window length in samples. */
|
||||
const val FFT_LENGTH = 256
|
||||
|
||||
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
|
||||
const val HOP_LENGTH = 48
|
||||
|
||||
/**
|
||||
* Lower edge of the model's analysis window. Public so [CwToneShifter] can
|
||||
* decide whether a detected tone falls outside it; the value is fixed by the
|
||||
* trained model and must not be changed without retraining.
|
||||
*/
|
||||
const val MIN_FREQ_HZ = 400.0
|
||||
|
||||
/** Upper edge of the model's analysis window; see [MIN_FREQ_HZ]. */
|
||||
const val MAX_FREQ_HZ = 1200.0
|
||||
|
||||
/** Number of frequency bins the model expects. */
|
||||
const val FREQUENCY_BINS = 65
|
||||
|
||||
/**
|
||||
* Widest span worth displaying: DC to Nyquist.
|
||||
*
|
||||
* The model reads [MIN_FREQ_HZ]..[MAX_FREQ_HZ], but a tone outside that range leaves
|
||||
* no trace inside it - measured on keyed audio, the brightest column in the narrow
|
||||
* view swings 1.01x between key-down and key-up, against 13.76x for a tone the model
|
||||
* can see. So the narrow view cannot even show that a signal exists, and the display
|
||||
* spans the whole band instead. Nothing above Nyquist can be shown at all: it aliases.
|
||||
*/
|
||||
const val DISPLAY_MIN_FREQ_HZ = 0.0
|
||||
|
||||
/** Upper end of the display span; see [DISPLAY_MIN_FREQ_HZ]. */
|
||||
const val DISPLAY_MAX_FREQ_HZ = SAMPLE_RATE / 2.0
|
||||
|
||||
/** Milliseconds of audio represented by one output frame. */
|
||||
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
|
||||
|
||||
private val hannWindow: FloatArray = FloatArray(FFT_LENGTH) { i ->
|
||||
// numpy: np.hanning(N + 1)[:-1] — the periodic (not symmetric) variant.
|
||||
(0.5 - 0.5 * cos(2.0 * PI * i / FFT_LENGTH)).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive-exclusive bin range covering [minHz, maxHz].
|
||||
* Returns `start to stop`, matching the reference's `frequency_bin_range`.
|
||||
*/
|
||||
fun frequencyBinRange(
|
||||
sampleRate: Int,
|
||||
fftLength: Int,
|
||||
minHz: Double,
|
||||
maxHz: Double
|
||||
): Pair<Int, Int> {
|
||||
val binHz = sampleRate.toDouble() / fftLength
|
||||
val start = ceil(minHz / binHz).toInt()
|
||||
val stop = floor(maxHz / binHz).toInt() + 1
|
||||
return start to stop
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolation resampler. Deliberately dependency-light and
|
||||
* identical to the reference implementation so spectrograms match.
|
||||
*/
|
||||
fun resampleLinear(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||
if (sourceRate == targetRate || audio.isEmpty()) return audio
|
||||
val targetLength = (audio.size.toDouble() * targetRate / sourceRate).roundToInt()
|
||||
val out = FloatArray(targetLength)
|
||||
val ratio = sourceRate.toDouble() / targetRate
|
||||
for (i in 0 until targetLength) {
|
||||
val position = i * ratio
|
||||
val left = floor(position).toInt()
|
||||
val right = minOf(left + 1, audio.size - 1)
|
||||
val fraction = (position - left).toFloat()
|
||||
out[i] = audio[left] * (1f - fraction) + audio[right] * fraction
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the log-magnitude spectrogram. Input must already be at
|
||||
* [SAMPLE_RATE]; use [resampleLinear] first when it is not.
|
||||
*
|
||||
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
|
||||
*/
|
||||
fun compute(
|
||||
audio: FloatArray,
|
||||
minHz: Double = MIN_FREQ_HZ,
|
||||
maxHz: Double = MAX_FREQ_HZ
|
||||
): Array<FloatArray> {
|
||||
require(audio.size >= FFT_LENGTH) {
|
||||
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
|
||||
}
|
||||
|
||||
val (startBin, stopBin) = frequencyBinRange(SAMPLE_RATE, FFT_LENGTH, minHz, maxHz)
|
||||
val bins = stopBin - startBin
|
||||
require(bins > 0) { "empty bin range for $minHz..${maxHz}Hz" }
|
||||
// The model's range must yield exactly the bin count it was trained on. Written as
|
||||
// an implication rather than a disjunction of all three terms: `a != x || b != y ||
|
||||
// bins == n` is satisfied by any custom range regardless of the bin count, which
|
||||
// would leave the invariant unenforced for the caller most likely to break it.
|
||||
val isModelRange = minHz == MIN_FREQ_HZ && maxHz == MAX_FREQ_HZ
|
||||
require(!isModelRange || bins == FREQUENCY_BINS) {
|
||||
"expected $FREQUENCY_BINS bins for the model range, computed $bins"
|
||||
}
|
||||
// Nothing may run off the end of the FFT output: a real signal has FFT_LENGTH / 2
|
||||
// + 1 distinct bins, and asking beyond Nyquist would index past them.
|
||||
require(stopBin <= FFT_LENGTH / 2 + 1) {
|
||||
"maxHz ${maxHz}Hz is above Nyquist ${SAMPLE_RATE / 2}Hz"
|
||||
}
|
||||
|
||||
val padded = reflectPad(audio, FFT_LENGTH / 2)
|
||||
val frames = 1 + (padded.size - FFT_LENGTH) / HOP_LENGTH
|
||||
val result = Array(frames) { FloatArray(bins) }
|
||||
|
||||
val real = FloatArray(FFT_LENGTH)
|
||||
val imag = FloatArray(FFT_LENGTH)
|
||||
for (frame in 0 until frames) {
|
||||
val offset = frame * HOP_LENGTH
|
||||
for (i in 0 until FFT_LENGTH) {
|
||||
real[i] = padded[offset + i] * hannWindow[i]
|
||||
imag[i] = 0f
|
||||
}
|
||||
fftInPlace(real, imag)
|
||||
val row = result[frame]
|
||||
for (bin in startBin until stopBin) {
|
||||
val magnitude = sqrt(real[bin] * real[bin] + imag[bin] * imag[bin])
|
||||
row[bin - startBin] = ln1p(magnitude.toDouble()).toFloat()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* numpy `mode="reflect"`: mirrors around the edge samples without
|
||||
* repeating them, so [1,2,3] padded by 2 becomes [3,2,1,2,3,2,1].
|
||||
*/
|
||||
private fun reflectPad(audio: FloatArray, pad: Int): FloatArray {
|
||||
if (pad == 0) return audio
|
||||
val out = FloatArray(audio.size + 2 * pad)
|
||||
for (i in 0 until pad) out[i] = audio[pad - i]
|
||||
audio.copyInto(out, pad)
|
||||
val last = audio.size - 1
|
||||
for (i in 0 until pad) out[pad + audio.size + i] = audio[last - 1 - i]
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative radix-2 Cooley-Tukey FFT. [FFT_LENGTH] is a power of two, so
|
||||
* no padding case is needed. Only the first half of the output is read by
|
||||
* [compute], which is the real-input equivalent of numpy's `rfft`.
|
||||
*/
|
||||
private fun fftInPlace(real: FloatArray, imag: FloatArray) {
|
||||
val n = real.size
|
||||
|
||||
// Bit-reversal permutation.
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) {
|
||||
j = j xor bit
|
||||
bit = bit shr 1
|
||||
}
|
||||
j = j or bit
|
||||
if (i < j) {
|
||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
var length = 2
|
||||
while (length <= n) {
|
||||
val angle = -2.0 * PI / length
|
||||
val wReal = cos(angle).toFloat()
|
||||
val wImag = kotlin.math.sin(angle).toFloat()
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
var curReal = 1f
|
||||
var curImag = 0f
|
||||
for (k in 0 until length / 2) {
|
||||
val evenReal = real[i + k]
|
||||
val evenImag = imag[i + k]
|
||||
val oddReal = real[i + k + length / 2]
|
||||
val oddImag = imag[i + k + length / 2]
|
||||
val mulReal = oddReal * curReal - oddImag * curImag
|
||||
val mulImag = oddReal * curImag + oddImag * curReal
|
||||
real[i + k] = evenReal + mulReal
|
||||
imag[i + k] = evenImag + mulImag
|
||||
real[i + k + length / 2] = evenReal - mulReal
|
||||
imag[i + k + length / 2] = evenImag - mulImag
|
||||
val nextReal = curReal * wReal - curImag * wImag
|
||||
curImag = curReal * wImag + curImag * wReal
|
||||
curReal = nextReal
|
||||
}
|
||||
i += length
|
||||
}
|
||||
length = length shl 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Pools capture chunks until enough audio is available for tone detection.
|
||||
*
|
||||
* [CwToneShifter.detectToneHz] scans bin by bin, so it needs a few hundred
|
||||
* milliseconds to resolve a pitch. A capture chunk is only 320 samples once
|
||||
* resampled to [CwDeepSpectrogram.SAMPLE_RATE], hence the pooling: without it a
|
||||
* per-chunk size check can never be satisfied and detection silently never runs.
|
||||
*
|
||||
* A ring buffer rather than a sliding array. Detection is throttled to a couple of
|
||||
* seconds while the pool fills in a few hundred milliseconds, so most chunks arrive
|
||||
* at a full buffer; shifting the array down one slot per sample cost 320 copies of
|
||||
* 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio on
|
||||
* the capture thread. Writing to a ring index is O(1).
|
||||
*
|
||||
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||
*
|
||||
* @param capacity samples retained; also the size [drain] returns once full.
|
||||
*/
|
||||
class CwDetectionPool(val capacity: Int) {
|
||||
|
||||
init {
|
||||
require(capacity > 0) { "capacity must be positive, was $capacity" }
|
||||
}
|
||||
|
||||
private val samples = FloatArray(capacity)
|
||||
private var writeIndex = 0
|
||||
|
||||
/** Samples currently pooled, never above [capacity]. */
|
||||
var size: Int = 0
|
||||
private set
|
||||
|
||||
/** True once [capacity] samples are pooled and detection can run. */
|
||||
val isReady: Boolean get() = size >= capacity
|
||||
|
||||
/** Add a chunk, overwriting the oldest samples once full. */
|
||||
fun add(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
// A chunk longer than the pool can only contribute its tail.
|
||||
val start = maxOf(0, chunk.size - capacity)
|
||||
for (i in start until chunk.size) {
|
||||
samples[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
if (size < capacity) size++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand over the pooled audio in chronological order and empty the pool.
|
||||
*
|
||||
* Oldest sample first: the detector measures a waveform, so returning the ring in
|
||||
* storage order would splice it at the wrap point and corrupt every estimate.
|
||||
*/
|
||||
fun drain(): FloatArray {
|
||||
val out = FloatArray(size)
|
||||
// Once full the oldest sample sits at the write cursor; before that at index 0.
|
||||
val oldest = if (size == capacity) writeIndex else 0
|
||||
for (i in 0 until size) {
|
||||
out[i] = samples[(oldest + i) % capacity]
|
||||
}
|
||||
clear()
|
||||
return out
|
||||
}
|
||||
|
||||
/** Discard everything pooled so far. */
|
||||
fun clear() {
|
||||
size = 0
|
||||
writeIndex = 0
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* DSP utilities for CW (Morse code) decoding.
|
||||
* Pure Kotlin, no NDK required.
|
||||
*/
|
||||
internal object CwDsp {
|
||||
|
||||
/**
|
||||
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
||||
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param taps filter length (must be odd)
|
||||
*/
|
||||
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
||||
val n = if (taps % 2 == 0) taps + 1 else taps
|
||||
val half = n / 2
|
||||
val coeffs = FloatArray(n)
|
||||
for (i in 0 until n) {
|
||||
val idx = i - half
|
||||
if (idx == 0) {
|
||||
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
||||
} else {
|
||||
val x = PI * idx
|
||||
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
||||
}
|
||||
// Hamming window
|
||||
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
||||
}
|
||||
// Normalize
|
||||
val sum = coeffs.sum()
|
||||
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
||||
return coeffs
|
||||
}
|
||||
|
||||
/** Apply FIR filter to a buffer. */
|
||||
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
||||
val out = FloatArray(buffer.size)
|
||||
for (i in buffer.indices) {
|
||||
var sum = 0f
|
||||
for (j in coeffs.indices) {
|
||||
val idx = i - j
|
||||
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Simple envelope detector: abs + low-pass smoothing. */
|
||||
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
||||
val env = FloatArray(signal.size)
|
||||
var s = 0f
|
||||
for (i in signal.indices) {
|
||||
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
||||
env[i] = s
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Estimate noise floor from envelope for adaptive thresholding. */
|
||||
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
||||
val sorted = env.sortedArray()
|
||||
val median = sorted[sorted.size / 2]
|
||||
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
||||
}
|
||||
|
||||
/** Simple Goertzel to detect a specific tone frequency. */
|
||||
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
||||
val omega = 2.0 * PI * targetFreq / sampleRate
|
||||
val coeff = 2.0 * cos(omega)
|
||||
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
||||
for (sample in buffer) {
|
||||
s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1; s1 = s0
|
||||
}
|
||||
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
||||
return sqrt(kotlin.math.abs(power)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Radix-2 FFT for real-valued input.
|
||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||
* Used by CwSpectrogram for time-frequency analysis.
|
||||
*/
|
||||
internal class CwFFT(private val n: Int) {
|
||||
init {
|
||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||
}
|
||||
|
||||
private val cosTable = FloatArray(n / 2)
|
||||
private val sinTable = FloatArray(n / 2)
|
||||
|
||||
init {
|
||||
for (i in 0 until n / 2) {
|
||||
val angle = -2.0 * kotlin.math.PI * i / n
|
||||
cosTable[i] = cos(angle).toFloat()
|
||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
||||
|
||||
val real = input.copyOf()
|
||||
val imag = FloatArray(n)
|
||||
|
||||
// Bit-reversal permutation
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||
j = j xor bit
|
||||
if (i < j) {
|
||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// Radix-2 Cooley-Tukey FFT
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val half = len / 2
|
||||
val step = n / len
|
||||
for (i in 0 until n step len) {
|
||||
for (k in 0 until half) {
|
||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||
real[i + k + half] = real[i + k] - tReal
|
||||
imag[i + k + half] = imag[i + k] - tImag
|
||||
real[i + k] += tReal
|
||||
imag[i + k] += tImag
|
||||
}
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first N/2+1 bins)
|
||||
val mag = FloatArray(n / 2 + 1)
|
||||
for (i in 0..n / 2) {
|
||||
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||
}
|
||||
return mag
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* First-order IIR filters.
|
||||
* Ported from ggmorse/src/filter.h
|
||||
*/
|
||||
internal class CwFilter {
|
||||
private var z1 = 0f
|
||||
|
||||
companion object {
|
||||
private const val PI_F = 3.141592653589793f
|
||||
}
|
||||
|
||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 = alpha * (z1 + sample - z1)
|
||||
return sample - z1
|
||||
}
|
||||
|
||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 += alpha * (sample - z1)
|
||||
return z1
|
||||
}
|
||||
|
||||
fun reset() { z1 = 0f }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Running Goertzel filter for CW tone detection.
|
||||
* Tracks a specific frequency over time with a sliding window.
|
||||
* Ported from ggmorse/src/goertzel.h
|
||||
*/
|
||||
internal class CwGoertzel {
|
||||
private var s1 = 0.0
|
||||
private var s2 = 0.0
|
||||
private var coeff = 0.0
|
||||
|
||||
fun init(sampleRate: Float, targetFreq: Float) {
|
||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||
coeff = 2.0 * cos(omega)
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
|
||||
fun process(sample: Float) {
|
||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
|
||||
fun getPower(): Float {
|
||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Simple linear resampler.
|
||||
* Downsamples from input sample rate to output sample rate.
|
||||
* Ported from ggmorse/src/resampler.h
|
||||
*/
|
||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||
private val ratio = inputRate / outputRate
|
||||
private var lastSample = 0f
|
||||
|
||||
fun process(input: FloatArray): FloatArray {
|
||||
if (ratio <= 0f || input.isEmpty()) return input
|
||||
val outputLen = (input.size / ratio).toInt() + 1
|
||||
val output = FloatArray(outputLen)
|
||||
var idx = 0f
|
||||
for (i in output.indices) {
|
||||
val intIdx = idx.toInt()
|
||||
val frac = idx - intIdx
|
||||
if (intIdx + 1 < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||
} else if (intIdx < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||
} else {
|
||||
output[i] = lastSample
|
||||
}
|
||||
idx += ratio
|
||||
}
|
||||
lastSample = input.lastOrNull() ?: lastSample
|
||||
return output
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSample = 0f
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||
*/
|
||||
internal class CwPitchDetector(
|
||||
private val sampleRate: Float,
|
||||
private val minFreq: Float = 200f,
|
||||
private val maxFreq: Float = 1200f,
|
||||
private val stepHz: Float = 10f
|
||||
) {
|
||||
/**
|
||||
* Find the dominant pitch frequency in the buffer.
|
||||
* Returns null if no significant pitch found.
|
||||
*/
|
||||
fun findPitch(buffer: FloatArray): Float? {
|
||||
if (buffer.isEmpty()) return null
|
||||
var bestFreq = 0f
|
||||
var bestPower = 0f
|
||||
var freq = minFreq
|
||||
while (freq <= maxFreq) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||
for (i in buffer.indices) {
|
||||
real += buffer[i] * cos(omega * i)
|
||||
imag += buffer[i] * -sin(omega * i)
|
||||
}
|
||||
val power = (real * real + imag * imag).toFloat()
|
||||
if (power > bestPower) {
|
||||
bestPower = power
|
||||
bestFreq = freq
|
||||
}
|
||||
freq += stepHz
|
||||
}
|
||||
return if (bestPower > 0.001f) bestFreq else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Decides what shift to apply from a sequence of tone estimates.
|
||||
*
|
||||
* Kept out of the decoder so the rule can be exercised directly. The decoder needs an
|
||||
* Android Context and a loaded ONNX session, so a rule living inside it can only be
|
||||
* tested by restating it - and a restated rule cannot fail when the real one is wrong.
|
||||
* Mutation testing proved that: four defects injected into an in-decoder version of this
|
||||
* logic left the whole suite green.
|
||||
*
|
||||
* @param hysteresisHz how far the tone must move before the shift is revised.
|
||||
*/
|
||||
class CwShiftDecider(private val hysteresisHz: Float = DEFAULT_HYSTERESIS_HZ) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default margin before re-shifting, in Hz.
|
||||
*
|
||||
* Detection resolves to 12.5 Hz and a real tone wanders, so a couple of scan bins
|
||||
* of jitter must not count as a retune: revising the shift costs the whole 20 s
|
||||
* decode window, which is worth far more than perfect centring.
|
||||
*/
|
||||
const val DEFAULT_HYSTERESIS_HZ = 40f
|
||||
}
|
||||
|
||||
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
|
||||
var shiftHz: Float = 0f
|
||||
private set
|
||||
|
||||
/**
|
||||
* Tone that produced [shiftHz]. Hysteresis compares against this rather than against
|
||||
* the previous shift, because a shift of 0 is a real state: at the window edge one
|
||||
* 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large
|
||||
* shift), and a shift-space comparison lapses exactly where the jump is largest.
|
||||
*/
|
||||
var anchorToneHz: Float? = null
|
||||
private set
|
||||
|
||||
/** What [accept] decided, for logging. */
|
||||
enum class Outcome {
|
||||
/** No tone in the window; the existing shift was retained. */
|
||||
NO_TONE,
|
||||
|
||||
/** The tone moved less than the margin; the existing shift was retained. */
|
||||
WITHIN_HYSTERESIS,
|
||||
|
||||
/** The tone is inside the model window, so no shift is needed. */
|
||||
NO_SHIFT_NEEDED,
|
||||
|
||||
/** The shift was updated to move an out-of-window tone into range. */
|
||||
SHIFTED
|
||||
}
|
||||
|
||||
/** Result of feeding one detection to the decider. */
|
||||
data class Decision(
|
||||
val outcome: Outcome,
|
||||
/** Shift in force after the decision. */
|
||||
val shiftHz: Float,
|
||||
/** True when [shiftHz] differs from the value before this decision. */
|
||||
val changed: Boolean,
|
||||
/** Tone the decision was based on, null when none was detected. */
|
||||
val toneHz: Float?
|
||||
)
|
||||
|
||||
/**
|
||||
* Feed one tone analysis and get the shift to apply.
|
||||
*
|
||||
* Silence retains the current shift rather than clearing it: CW is keyed, so a
|
||||
* detection window landing in a gap carries no information about the pitch. Treating
|
||||
* it as an authoritative "no shift" collapsed established shifts - measured over
|
||||
* 180 s of keyed audio at 1400 Hz, 11 of 90 windows saw no tone, and each one left
|
||||
* the following audio unshifted and therefore invisible to the model.
|
||||
*/
|
||||
fun accept(analysis: CwToneShifter.Analysis): Decision {
|
||||
val previousShift = shiftHz
|
||||
val toneHz = analysis.toneHz
|
||||
?: return Decision(Outcome.NO_TONE, previousShift, changed = false, toneHz = null)
|
||||
|
||||
val anchor = anchorToneHz
|
||||
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) {
|
||||
return Decision(Outcome.WITHIN_HYSTERESIS, previousShift, changed = false, toneHz = toneHz)
|
||||
}
|
||||
|
||||
shiftHz = analysis.shiftHz
|
||||
anchorToneHz = toneHz
|
||||
val outcome = if (analysis.needsShift) Outcome.SHIFTED else Outcome.NO_SHIFT_NEEDED
|
||||
return Decision(outcome, shiftHz, changed = shiftHz != previousShift, toneHz = toneHz)
|
||||
}
|
||||
|
||||
/** Forget the current shift and anchor, e.g. when the feature is toggled or reset. */
|
||||
fun reset() {
|
||||
shiftHz = 0f
|
||||
anchorToneHz = null
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Sliding-window spectrogram for CW decoding.
|
||||
* Maintains a time-frequency matrix updated with each audio frame.
|
||||
*
|
||||
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
||||
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
||||
* History: 40 columns (320 ms window)
|
||||
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
||||
*/
|
||||
internal class CwSpectrogram(
|
||||
private val fftSize: Int = 256,
|
||||
private val hopSize: Int = 64,
|
||||
private val sampleRate: Int = 4000,
|
||||
private val minBin: Int = 6,
|
||||
private val maxBin: Int = 38,
|
||||
val historyCols: Int = 40
|
||||
) {
|
||||
private val fft = CwFFT(fftSize)
|
||||
val numBins: Int get() = maxBin - minBin + 1
|
||||
|
||||
// Hanning window
|
||||
private val hanning = FloatArray(fftSize) {
|
||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||
}
|
||||
|
||||
// Spectrogram data: [timeCol][freqBin]
|
||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||
private var currentCol = 0
|
||||
private var samplesBuffered = 0
|
||||
private val buffer = FloatArray(fftSize)
|
||||
|
||||
// Per-bin running energy for normalization
|
||||
private val binEnergy = FloatArray(numBins) { 1f }
|
||||
private val alpha = 0.95f
|
||||
|
||||
// Counter for new columns generated since last check
|
||||
private var newColumnCount = 0
|
||||
|
||||
/** Add audio samples, compute FFTs for each complete hop. */
|
||||
fun addSamples(samples: FloatArray) {
|
||||
var offset = 0
|
||||
while (offset < samples.size) {
|
||||
val needed = fftSize - samplesBuffered
|
||||
val copyLen = minOf(needed, samples.size - offset)
|
||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||
samplesBuffered += copyLen
|
||||
offset += copyLen
|
||||
|
||||
if (samplesBuffered >= fftSize) {
|
||||
processFrame()
|
||||
newColumnCount++
|
||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||
samplesBuffered = fftSize - hopSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get number of new columns generated since the last call to this method. */
|
||||
fun getNewColumns(): Int {
|
||||
val count = newColumnCount
|
||||
newColumnCount = 0
|
||||
return count
|
||||
}
|
||||
|
||||
private fun processFrame() {
|
||||
// Apply Hanning window
|
||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||
|
||||
// Compute FFT magnitude spectrum
|
||||
val mag = fft.magnitudeSpectrum(windowed)
|
||||
|
||||
// Update spectrogram column
|
||||
val col = spectrogram[currentCol]
|
||||
for (b in 0 until numBins) {
|
||||
val binIdx = minBin + b
|
||||
val rawMag = mag[binIdx]
|
||||
// Running energy normalization
|
||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||
}
|
||||
|
||||
currentCol = (currentCol + 1) % historyCols
|
||||
}
|
||||
|
||||
/** Get the current spectrogram as a 2D array in chronological order. */
|
||||
fun getSpectrogram(): Array<FloatArray> {
|
||||
val result = Array(historyCols) { i ->
|
||||
val srcIdx = (currentCol + i) % historyCols
|
||||
spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the most recent column (current energy across all frequencies). */
|
||||
fun getCurrentColumn(): FloatArray {
|
||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||
return spectrogram[prevCol].copyOf()
|
||||
}
|
||||
|
||||
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
||||
fun getColumn(index: Int): FloatArray {
|
||||
val clamped = index.coerceIn(0, historyCols - 1)
|
||||
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
||||
return spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
|
||||
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
||||
fun findPeakBin(): Int {
|
||||
val col = getCurrentColumn()
|
||||
var maxBin = -1
|
||||
var maxVal = 0f
|
||||
for (i in col.indices) {
|
||||
if (col[i] > maxVal) {
|
||||
maxVal = col[i]
|
||||
maxBin = i
|
||||
}
|
||||
}
|
||||
return if (maxVal > 0.3f) maxBin else -1
|
||||
}
|
||||
|
||||
/** Get energy at a specific bin over the last N columns in chronological order. */
|
||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||
val clamped = minOf(numCols, historyCols)
|
||||
val result = FloatArray(clamped)
|
||||
for (i in 0 until clamped) {
|
||||
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
||||
result[i] = spectrogram[colIdx][bin]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the bin index for a frequency in Hz. */
|
||||
fun freqToBin(freqHz: Float): Int {
|
||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||
}
|
||||
|
||||
/** Get the center frequency for a bin. */
|
||||
fun binToFreq(bin: Int): Float {
|
||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (col in spectrogram) col.fill(0f)
|
||||
currentCol = 0
|
||||
samplesBuffered = 0
|
||||
buffer.fill(0f)
|
||||
binEnergy.fill(1f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Moves an out-of-range CW tone into the model's analysis window.
|
||||
*
|
||||
* The DeepCW model only sees [CwDeepSpectrogram.MIN_FREQ_HZ]..[CwDeepSpectrogram.MAX_FREQ_HZ];
|
||||
* its input tensor width is fixed, so the window itself cannot be widened without
|
||||
* retraining. Instead a tone that sits outside the window is frequency-shifted to
|
||||
* [TARGET_HZ] before the spectrogram is built, which extends the usable pitch range
|
||||
* to roughly 100 Hz..Nyquist without touching the model.
|
||||
*
|
||||
* ### Why single-sideband mixing
|
||||
* Plain real mixing (`x * cos(2*pi*delta*t)`) produces both `tone+delta` and
|
||||
* `tone-delta`. Measured on a 1500 Hz tone shifted to 800 Hz, the unwanted image
|
||||
* folded back to 1000 Hz at 0.999 of the wanted amplitude — inside the window and
|
||||
* as loud as the signal. Upsampling first only moves the problem: shifting a 300 Hz
|
||||
* tone up produced a 200 Hz image at 0.996.
|
||||
*
|
||||
* A Hilbert transformer removes the negative-frequency half first, so mixing the
|
||||
* resulting analytic signal yields one sideband only. Across nine probe tones
|
||||
* (150..1550 Hz) that leaves a single spectral peak at the target with no component
|
||||
* above 0.3 relative amplitude.
|
||||
*
|
||||
* All functions are pure; the caller decides whether shifting is wanted.
|
||||
*/
|
||||
object CwToneShifter {
|
||||
|
||||
/**
|
||||
* Where an out-of-window tone is moved to: the centre of the analysis window,
|
||||
* so the keying sidebands have equal headroom on both sides.
|
||||
*/
|
||||
const val TARGET_HZ = 800.0
|
||||
|
||||
/**
|
||||
* Tones below this are treated as absent rather than shifted. Mains hum and DC
|
||||
* drift live down here, and a real CW note that low is unusable anyway.
|
||||
*/
|
||||
const val MIN_DETECTABLE_HZ = 100.0
|
||||
|
||||
/**
|
||||
* A detected peak must exceed the spectrum mean by this factor to count as a tone.
|
||||
*
|
||||
* Chosen from measurements on 1280-sample (400 ms) windows of keyed CW in noise.
|
||||
* Pure noise peaks at 2.2-3.4 times its own spectral mean, so 3.0 admitted roughly
|
||||
* one noise window in five. Raising it as far as 8.0 then rejected comfortably
|
||||
* copyable signals: keyed CW measures 7.6-9.0 at 0 dB SNR and only 5.2-6.7 at -3 dB.
|
||||
*
|
||||
* 4.5 gives zero false positives across 40 noise windows while keeping the weaker
|
||||
* end of usable signals. The asymmetry is deliberate: a false tone is worse than a
|
||||
* missed one, because it moves a perfectly good signal out of the model's range,
|
||||
* whereas a miss just leaves the audio alone until a stronger window arrives.
|
||||
*
|
||||
* Windows dominated by keying gaps (a slow fist, under ~25% tone) sit at 2.4 and are
|
||||
* indistinguishable from noise at any threshold; those are skipped, not guessed at.
|
||||
*/
|
||||
const val MIN_PROMINENCE = 4.5
|
||||
|
||||
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
|
||||
private const val HILBERT_TAPS = 63
|
||||
|
||||
/** Frequency resolution of [detectToneHz], in Hz. */
|
||||
private const val DETECT_STEP_HZ = 12.5
|
||||
|
||||
/** Windowed Hilbert transformer: h[n] = 2/(pi*n) for odd n, 0 otherwise. */
|
||||
private val hilbertKernel: FloatArray = FloatArray(HILBERT_TAPS) { i ->
|
||||
val n = i - HILBERT_TAPS / 2
|
||||
val ideal = if (n == 0 || n % 2 == 0) 0.0 else 2.0 / (PI * n)
|
||||
// Hamming window; without it the truncated kernel ripples badly.
|
||||
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (HILBERT_TAPS - 1))
|
||||
(ideal * window).toFloat()
|
||||
}
|
||||
|
||||
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
|
||||
private const val HILBERT_DELAY = HILBERT_TAPS / 2
|
||||
|
||||
/** Outcome of inspecting a chunk of audio. */
|
||||
data class Analysis(
|
||||
/** Detected tone in Hz, or null when the audio is noise. */
|
||||
val toneHz: Float?,
|
||||
/** True when [toneHz] sits outside the model's window and can be shifted. */
|
||||
val needsShift: Boolean,
|
||||
/** Hz the tone would be moved by; 0 when no shift applies. */
|
||||
val shiftHz: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Estimate the dominant tone by scanning [MIN_DETECTABLE_HZ]..Nyquist with a
|
||||
* Goertzel-style single-bin DFT.
|
||||
*
|
||||
* Deliberately not reusing [CwDeepSpectrogram]: that clips to the model window,
|
||||
* which is exactly the region an out-of-range tone is *not* in.
|
||||
*
|
||||
* @return the peak frequency, or null when nothing stands out from the noise.
|
||||
*/
|
||||
fun detectToneHz(audio: FloatArray, sampleRate: Int): Float? {
|
||||
if (audio.size < 64) return null
|
||||
val nyquist = sampleRate / 2.0
|
||||
// A Hann window stops the scan from smearing energy across neighbours.
|
||||
val window = FloatArray(audio.size) { i ->
|
||||
(0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))).toFloat()
|
||||
}
|
||||
|
||||
var bestHz = 0.0
|
||||
var bestMagnitude = 0.0
|
||||
var total = 0.0
|
||||
var bins = 0
|
||||
|
||||
var hz = MIN_DETECTABLE_HZ
|
||||
while (hz <= nyquist) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val value = audio[i] * window[i]
|
||||
real += value * cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
val magnitude = hypot(real, imag) / audio.size
|
||||
total += magnitude
|
||||
bins++
|
||||
if (magnitude > bestMagnitude) {
|
||||
bestMagnitude = magnitude
|
||||
bestHz = hz
|
||||
}
|
||||
hz += DETECT_STEP_HZ
|
||||
}
|
||||
|
||||
if (bins == 0 || bestMagnitude <= 0.0) return null
|
||||
val mean = total / bins
|
||||
// Pure noise has a flat spectrum, so the peak barely beats the mean.
|
||||
if (mean <= 0.0 || bestMagnitude < mean * MIN_PROMINENCE) return null
|
||||
return bestHz.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether [audio] needs shifting, without modifying it.
|
||||
*
|
||||
* A tone already inside the window is left alone: shifting it would add filter
|
||||
* ringing and rounding for no benefit, and the model handles it natively.
|
||||
*/
|
||||
fun analyse(audio: FloatArray, sampleRate: Int): Analysis {
|
||||
val tone = detectToneHz(audio, sampleRate)
|
||||
?: return Analysis(toneHz = null, needsShift = false, shiftHz = 0f)
|
||||
val inWindow = tone >= CwDeepSpectrogram.MIN_FREQ_HZ && tone <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
if (inWindow) return Analysis(toneHz = tone, needsShift = false, shiftHz = 0f)
|
||||
return Analysis(
|
||||
toneHz = tone,
|
||||
needsShift = true,
|
||||
shiftHz = (TARGET_HZ - tone).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift [audio] by [shiftHz] using single-sideband mixing.
|
||||
*
|
||||
* The Hilbert transformer suppresses the negative-frequency half, so only the
|
||||
* wanted sideband survives; see the class docs for the measured alternative.
|
||||
* Returns a new array; [audio] is not modified.
|
||||
*
|
||||
* Stateless: [audio] is treated as an isolated signal, so the first and last
|
||||
* [HILBERT_DELAY] samples convolve against zeros instead of the neighbouring
|
||||
* audio. Fine for a whole buffer, but it corrupts 62 of every 320 samples when
|
||||
* called per capture chunk, so streaming callers must use [Streaming].
|
||||
*/
|
||||
fun shift(audio: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || audio.isEmpty()) return audio
|
||||
|
||||
// Quadrature path: audio convolved with the Hilbert kernel.
|
||||
val quadrature = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = i - k + HILBERT_DELAY
|
||||
if (j >= 0 && j < audio.size) sum += hilbertKernel[k] * audio[j]
|
||||
}
|
||||
quadrature[i] = sum
|
||||
}
|
||||
|
||||
// Re{(inPhase + j*quadrature) * e^(j*2*pi*shift*t)}
|
||||
val out = FloatArray(audio.size)
|
||||
val step = 2.0 * PI * shiftHz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val phase = step * i
|
||||
out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk shifter that carries the state [shift] cannot.
|
||||
*
|
||||
* Two things must survive across calls for concatenated chunks to form a clean
|
||||
* signal:
|
||||
*
|
||||
* 1. **Filter history.** The Hilbert FIR spans [HILBERT_TAPS] samples, so the
|
||||
* first outputs of a chunk need the previous chunk's tail. Without it those
|
||||
* samples convolve against zeros; measured on 320-sample chunks that distorts
|
||||
* 62 of them (19%) and inflates envelope ripple to 8.7x the whole-buffer
|
||||
* baseline.
|
||||
* 2. **Mixer phase.** Restarting the local oscillator at zero every chunk puts a
|
||||
* phase step at every boundary.
|
||||
*
|
||||
* One difference from [shift] remains and is unavoidable: output sample `i` ideally
|
||||
* needs input up to `i + HILBERT_DELAY`, which for the last samples of a chunk has
|
||||
* not been captured yet. Those trailing taps therefore see zeros. Measured against
|
||||
* a whole-buffer shift the divergence is confined to the final 3 samples of each
|
||||
* 320-sample chunk and disappears immediately after the boundary — under 1% of the
|
||||
* audio, versus a 20 WPM dot spanning 192 samples. Buffering a chunk to remove it
|
||||
* would add 10 ms of latency for no decoding benefit.
|
||||
*
|
||||
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||
*/
|
||||
class Streaming {
|
||||
|
||||
private val history = FloatArray(HILBERT_TAPS - 1)
|
||||
private var phase = 0.0
|
||||
|
||||
/** Shift one chunk, continuing the filter and oscillator state. */
|
||||
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || chunk.isEmpty()) {
|
||||
// Still advance the history, so enabling a shift later starts from real
|
||||
// audio rather than the silence left over from before.
|
||||
pushHistory(chunk)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// Convolve over [history || chunk] so every output sees real samples.
|
||||
val combined = FloatArray(history.size + chunk.size)
|
||||
history.copyInto(combined)
|
||||
chunk.copyInto(combined, history.size)
|
||||
|
||||
val out = FloatArray(chunk.size)
|
||||
val step = 2.0 * PI * shiftHz / sampleRate
|
||||
for (i in chunk.indices) {
|
||||
val centre = history.size + i
|
||||
var quadrature = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = centre - k + HILBERT_DELAY
|
||||
if (j >= 0 && j < combined.size) quadrature += hilbertKernel[k] * combined[j]
|
||||
}
|
||||
val currentPhase = phase + step * i
|
||||
val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase)
|
||||
out[i] = clampToUnit(mixed)
|
||||
}
|
||||
|
||||
// Keep the phase bounded; letting it grow loses float precision.
|
||||
phase = (phase + step * chunk.size) % (2.0 * PI)
|
||||
pushHistory(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear filter history and phase, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
history.fill(0f)
|
||||
phase = 0.0
|
||||
}
|
||||
|
||||
/** Keep the most recent [history] samples of the stream. */
|
||||
private fun pushHistory(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
if (chunk.size >= history.size) {
|
||||
chunk.copyInto(history, 0, chunk.size - history.size, chunk.size)
|
||||
} else {
|
||||
history.copyInto(history, 0, chunk.size, history.size)
|
||||
chunk.copyInto(history, history.size - chunk.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: analyse [audio] and shift it only when the tone is
|
||||
* outside the model window.
|
||||
*
|
||||
* @return the audio to feed the model (the original array when no shift was
|
||||
* needed) paired with the [Analysis] that produced the decision, so callers
|
||||
* can log what happened.
|
||||
*/
|
||||
fun shiftIfOutsideWindow(audio: FloatArray, sampleRate: Int): Pair<FloatArray, Analysis> {
|
||||
val analysis = analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio to analysis
|
||||
return shift(audio, analysis.shiftHz, sampleRate) to analysis
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a mixed sample inside the +/-1.0 range the spectrogram assumes.
|
||||
*
|
||||
* The Hilbert kernel has an L1 gain of 2.51, so summing the in-phase and quadrature
|
||||
* paths can exceed unity even for a full-scale sine (measured 1.05 at 1500 Hz, 2.35
|
||||
* for a square wave). The spectrogram takes log1p of the magnitude, so an overshoot
|
||||
* is not fatal, but it shifts the level the model was trained on.
|
||||
*/
|
||||
private fun clampToUnit(value: Double): Float = when {
|
||||
value > 1.0 -> 1f
|
||||
value < -1.0 -> -1f
|
||||
else -> value.toFloat()
|
||||
}
|
||||
|
||||
/** True when [toneHz] lies inside the model's analysis window. */
|
||||
fun isInsideWindow(toneHz: Float): Boolean =
|
||||
toneHz >= CwDeepSpectrogram.MIN_FREQ_HZ && toneHz <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A CW (Morse code) decoder fed with microphone PCM.
|
||||
*
|
||||
* Implementations live outside `core:domain` when they need platform APIs;
|
||||
* this contract stays pure Kotlin so the UI can depend on it directly.
|
||||
*/
|
||||
interface ICwDecoder {
|
||||
|
||||
/**
|
||||
* Decoded text for the *current* window.
|
||||
*
|
||||
* Note this is **replace** semantics, not append: a whole-segment model
|
||||
* revises earlier characters as more audio arrives, so consumers must show
|
||||
* the current value rather than accumulating emissions.
|
||||
*/
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Permanent transcript of everything that has scrolled out of the live
|
||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||
* what the user reads back after a signal has passed.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Pitch of the tone the model is decoding, in Hz, or null before one is found.
|
||||
*
|
||||
* Derived from the spectrogram, so it can only ever report a frequency inside the
|
||||
* model's analysis window. For the pitch of a tone the model cannot see, use
|
||||
* [detectedToneHz].
|
||||
*/
|
||||
val estimatedPitch: StateFlow<Float?>
|
||||
|
||||
/**
|
||||
* Pitch of the loudest tone in the raw audio, in Hz, or null when none stands out.
|
||||
*
|
||||
* Unlike [estimatedPitch] this is measured before any shifting and over the full
|
||||
* audio bandwidth, so it can report a tone the model's window excludes — which is
|
||||
* the only way to tell the operator that nothing is being decoded because their tone
|
||||
* is out of range.
|
||||
*/
|
||||
val detectedToneHz: StateFlow<Float?>
|
||||
|
||||
/** Current shift applied to bring the tone into the model's window, 0f when idle. */
|
||||
val activeShiftHz: StateFlow<Float>
|
||||
|
||||
/** Relative signal strength in 0..1 for level meters. */
|
||||
val signalStrength: StateFlow<Float>
|
||||
|
||||
/** Most recent inference duration in milliseconds, for diagnostics. */
|
||||
val lastInferenceMs: StateFlow<Int>
|
||||
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||
|
||||
/** Clear decoded text and buffered audio. */
|
||||
fun reset()
|
||||
|
||||
/** Release native resources. Must be called when the decoder goes away. */
|
||||
fun close()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
val call: String, // 呼号
|
||||
val grid: String, // 网格坐标(可为空)
|
||||
val dateUtc: String, // 2026-08-04
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay>, // 3 天(新→旧)
|
||||
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
val reports: Map<String, SatReport> // id → 报告
|
||||
)
|
||||
@@ -29,8 +29,7 @@ data class PassesSettings(
|
||||
val minElevation: Double,
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
val invertAosTimeWindow: Boolean = false
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -42,6 +41,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -62,7 +62,36 @@ data class OtherSettings(
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0
|
||||
val highElevation: Double = 45.0,
|
||||
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
|
||||
val hiddenScreens: List<String> = emptyList(),
|
||||
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
|
||||
val screenOrder: List<String> = emptyList(),
|
||||
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
|
||||
val subMenuOrder: List<String> = emptyList(),
|
||||
// WaveLog logging (4.5.2): server config
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false,
|
||||
// Upstream radar compass offset (merged from rt-bishop)
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f,
|
||||
/**
|
||||
* Shift a CW tone that sits outside the model's 400-1200 Hz analysis window into
|
||||
* it before decoding. Off by default: when disabled the audio path is unchanged,
|
||||
* and a tone already inside the window is never touched either way.
|
||||
*/
|
||||
val cwToneShiftEnabled: Boolean = false,
|
||||
|
||||
/**
|
||||
* Draw each AMSAT day as twelve two-hour stripes rather than one colour.
|
||||
*
|
||||
* On by default: a single colour is taken from the first slot with a report, so a
|
||||
* satellite that worked all morning and failed all afternoon looks identical to one
|
||||
* that worked once. Some operators prefer the older, simpler tile, hence the switch.
|
||||
*/
|
||||
val amsatDayStripes: Boolean = true
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.navigation
|
||||
|
||||
/**
|
||||
* Single source of truth for the navigation menu layout.
|
||||
*
|
||||
* The bottom bar holds at most [MAIN_SLOTS] pages; the rest live behind the More
|
||||
* button. Both the bar and the settings editor resolve through here, so the list
|
||||
* the user edits is exactly the list they get.
|
||||
*
|
||||
* Lives in `core:domain` (pure Kotlin) so it is unit-testable and KMP-ready.
|
||||
*/
|
||||
object MenuLayout {
|
||||
|
||||
/** Bottom-bar capacity, including the Settings entry. */
|
||||
const val MAIN_SLOTS = 5
|
||||
|
||||
/** Must stay reachable from some menu, so the user cannot lock themselves out. */
|
||||
const val SETTINGS_ID = "Settings"
|
||||
|
||||
/** Bar contents for a fresh install. */
|
||||
val defaultMainOrder = listOf("Satellites", "Passes", "Radar", "Map", SETTINGS_ID)
|
||||
|
||||
/** More-menu contents for a fresh install. */
|
||||
val defaultMoreOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
/** What the bar and the More menu actually show. */
|
||||
data class Layout(val mainIds: List<String>, val moreIds: List<String>)
|
||||
|
||||
/** A menu assignment ready to be persisted to settings. */
|
||||
data class Assignment(val screenOrder: List<String>, val subMenuOrder: List<String>)
|
||||
|
||||
/**
|
||||
* Map persisted preferences onto the two menus.
|
||||
*
|
||||
* A page named by neither persisted list is new to this install and follows
|
||||
* the defaults, so upgrades never lose pages. Visible pages that overflow
|
||||
* [MAIN_SLOTS] fall through to the More menu instead of disappearing, and
|
||||
* [SETTINGS_ID] always survives the slot cut.
|
||||
*/
|
||||
fun resolve(
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>,
|
||||
hiddenScreenIds: List<String>
|
||||
): Layout {
|
||||
val visible = allScreenIds.filter { it !in hiddenScreenIds || it == SETTINGS_ID }
|
||||
val wantMain = ArrayList<String>()
|
||||
val wantMore = ArrayList<String>()
|
||||
for (id in visible) {
|
||||
when {
|
||||
id in screenOrder -> wantMain.add(id)
|
||||
id in subMenuOrder -> wantMore.add(id)
|
||||
id in defaultMainOrder -> wantMain.add(id)
|
||||
else -> wantMore.add(id)
|
||||
}
|
||||
}
|
||||
wantMain.sortBy { rank(it, screenOrder, defaultMainOrder) }
|
||||
wantMore.sortBy { rank(it, subMenuOrder, defaultMoreOrder) }
|
||||
|
||||
// Reserve the Settings slot before cutting so it cannot be truncated away.
|
||||
val settingsOnBar = SETTINGS_ID in wantMain
|
||||
val budget = if (settingsOnBar) MAIN_SLOTS - 1 else MAIN_SLOTS
|
||||
val main = ArrayList<String>(MAIN_SLOTS)
|
||||
for (id in wantMain) {
|
||||
if (id == SETTINGS_ID) continue
|
||||
if (main.size == budget) break
|
||||
main.add(id)
|
||||
}
|
||||
if (settingsOnBar) main.add(SETTINGS_ID)
|
||||
|
||||
val overflow = wantMain.filter { it !in main }
|
||||
return Layout(mainIds = main, moreIds = overflow + wantMore)
|
||||
}
|
||||
|
||||
/** Move [screenId] onto the bar, evicting the last movable page when full. */
|
||||
fun moveToMain(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val main = current.mainIds.toMutableList()
|
||||
val more = current.moreIds.toMutableList()
|
||||
val wasInMore = screenId in more
|
||||
more.remove(screenId)
|
||||
if (screenId !in main) {
|
||||
val at = main.indexOf(SETTINGS_ID).let { if (it == -1) main.size else it }
|
||||
main.add(at, screenId)
|
||||
}
|
||||
// Only evict when we actually added a new page from More; internal reordering must not evict.
|
||||
if (wasInMore) {
|
||||
val movable = main.filter { it != SETTINGS_ID && it != screenId }
|
||||
if (main.size > MAIN_SLOTS && movable.isNotEmpty()) {
|
||||
val evicted = movable.last()
|
||||
main.remove(evicted)
|
||||
more.add(0, evicted)
|
||||
}
|
||||
}
|
||||
return Assignment(screenOrder = main, subMenuOrder = more)
|
||||
}
|
||||
|
||||
/** Move [screenId] off the bar; Settings is refused so it stays reachable. */
|
||||
fun moveToMore(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
if (screenId == SETTINGS_ID) return Assignment(screenOrder, subMenuOrder)
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val more = current.moreIds.toMutableList()
|
||||
if (screenId !in more) more.add(screenId)
|
||||
return Assignment(
|
||||
screenOrder = current.mainIds.filter { it != screenId },
|
||||
subMenuOrder = more
|
||||
)
|
||||
}
|
||||
|
||||
private fun rank(id: String, persisted: List<String>, fallback: List<String>): Int {
|
||||
val persistedIndex = persisted.indexOf(id)
|
||||
if (persistedIndex != -1) return persistedIndex
|
||||
val fallbackIndex = fallback.indexOf(id)
|
||||
return if (fallbackIndex != -1) fallbackIndex else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -424,8 +424,12 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
|
||||
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
|
||||
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
|
||||
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
|
||||
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
|
||||
* never built into the app.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.qrz
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URL
|
||||
|
||||
object QrzGridClient {
|
||||
|
||||
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
|
||||
fun parseCookies(raw: String): String {
|
||||
val text = raw.trim()
|
||||
if (text.isEmpty()) return ""
|
||||
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
|
||||
if (text.startsWith("[")) {
|
||||
return try {
|
||||
val arr = org.json.JSONArray(text)
|
||||
val parts = mutableListOf<String>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name")
|
||||
val value = o.optString("value")
|
||||
if (name.isNotBlank()) parts.add("$name=$value")
|
||||
}
|
||||
parts.joinToString("; ")
|
||||
} catch (_: Exception) { text }
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
|
||||
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||
if (cookieHeader.isBlank()) return@withContext null
|
||||
try {
|
||||
val url = URL("https://www.qrz.com/db/")
|
||||
val conn = url.openConnection()
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 20_000
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||
conn.setRequestProperty("Cookie", cookieHeader)
|
||||
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
|
||||
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
|
||||
pattern.find(html)?.groupValues?.get(1)?.trim()
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
|
||||
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
|
||||
try {
|
||||
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
|
||||
val conn = url.openConnection()
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 20_000
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||
conn.setRequestProperty("Cookie", cookieHeader)
|
||||
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||
// Detail table Grid Square row (verified format)
|
||||
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
|
||||
.find(html)
|
||||
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
}
|
||||
+8
@@ -30,10 +30,12 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
fun provideNetworkReporter(): IReporter
|
||||
@@ -41,7 +43,13 @@ interface IMainContainer {
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||
fun provideSaveImage(): ISaveImage
|
||||
// WaveLog logging (4.5.2)
|
||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
|
||||
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
|
||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
|
||||
+9
-3
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
val selectedTypes: StateFlow<List<String>>
|
||||
val selectedSatModes: StateFlow<List<String>>
|
||||
fun setSelectedIds(ids: List<Int>)
|
||||
fun setSelectedTypes(types: List<String>)
|
||||
fun setSelectedSatModes(modes: List<String>)
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
||||
//region # Station position settings
|
||||
val stationPosition: StateFlow<GeoPos>
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
|
||||
suspend fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
//endregion
|
||||
|
||||
@@ -77,4 +78,9 @@ interface ISettingsRepo {
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
}
|
||||
@@ -22,4 +22,15 @@ import java.io.InputStream
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
|
||||
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
|
||||
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
|
||||
/** Fetch AMSAT API summary for the past N hours (JSON string; null on failure).
|
||||
* Used to compare against the global reports response and flag satellites whose data
|
||||
* was crowded out of the 500-record cap. */
|
||||
suspend fun getAmSatSummary(hours: Int): String?
|
||||
}
|
||||
@@ -18,6 +18,10 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
|
||||
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
|
||||
val satelliteDataUrls = mapOf(
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
@@ -48,6 +52,15 @@ object Sources {
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val satelliteModes = listOf(
|
||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
||||
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
||||
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
||||
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
||||
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
||||
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
|
||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
||||
|
||||
interface IShowToast {
|
||||
operator fun invoke(message: String)
|
||||
|
||||
/** Show by resource ID (four-language text) */
|
||||
operator fun invoke(resId: Int)
|
||||
}
|
||||
@@ -70,8 +70,13 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
||||
// Add the day fraction numerically. Building it by string surgery breaks
|
||||
// below 1e-3, where Double.toString() switches to scientific notation and
|
||||
// dropping the first character removes a significant digit instead of the
|
||||
// leading zero: 00:01:00 yielded "25001.944444444444445E-4" -> 2.50019,
|
||||
// a silently valid epoch about 26 years off.
|
||||
val dayFraction = (hour + min + sec + ms) / 86400000.0
|
||||
val epoch = "${year.substring(2)}$day".toDouble() + dayFraction
|
||||
OrbitalData(
|
||||
name = name,
|
||||
epoch = epoch,
|
||||
@@ -91,7 +96,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
val line1 = tle[1]
|
||||
val line2 = tle[2]
|
||||
OrbitalData(
|
||||
name = tle[0].trim(),
|
||||
name = tle[0].trim().removePrefix("0 "),
|
||||
epoch = line1.substring(18, 32).toDouble(),
|
||||
meanmo = line2.substring(52, 63).toDouble(),
|
||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||
|
||||
+46
-1
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||
* with an offset applied to the downlink (in Hz).
|
||||
* Returns null if the transponder is not a linear passband type.
|
||||
*
|
||||
* The user-entered downlink frequency already includes the offset, so subtract
|
||||
* it before mapping the downlink passband position back to the uplink.
|
||||
*/
|
||||
fun computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz: Long,
|
||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
||||
offsetHz: Long
|
||||
): Long? {
|
||||
if (!isLinearTransponder(transponder)) return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||
return orbitalPos.getUplinkFreq(baseUplink)
|
||||
}
|
||||
|
||||
@@ -95,4 +99,45 @@ object DopplerFrequencyCalculator {
|
||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||
&& upLow != upHigh && downLow != downHigh
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the radio entry that should drive the standalone Calculator page.
|
||||
*
|
||||
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||
*/
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
|
||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||
(hasLinearName && hasLinearMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate transponder entries that describe the same physical
|
||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||
* "CW Transponder" and "SSB Transponder").
|
||||
*
|
||||
* Entries sharing the same uplink/downlink frequency range are considered
|
||||
* the same transponder. The non-CW entry is preferred because its invert
|
||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||
*/
|
||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||
return radios.groupBy { radio ->
|
||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||
}.values.map { group ->
|
||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
fun String.aprsPasscode(): Int {
|
||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||
var hash = 0x73E2
|
||||
var i = 0
|
||||
while (i < callsign.length) {
|
||||
hash = hash xor (callsign[i].code shl 8)
|
||||
i++
|
||||
if (i < callsign.length) {
|
||||
hash = hash xor callsign[i].code
|
||||
i++
|
||||
}
|
||||
}
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
//fun String.getHash(type: String = "SHA-256"): String {
|
||||
// val hexChars = "0123456789ABCDEF"
|
||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||
|
||||
+11
-2
@@ -81,9 +81,18 @@ fun clipLat(latitude: Double): Double {
|
||||
}
|
||||
|
||||
fun clipLon(longitude: Double): Double {
|
||||
// Reduce with a modulo so a single pass always terminates. The previous
|
||||
// while-loop never returned for extreme inputs: Infinity stays Infinity
|
||||
// after subtracting 360, so the loop ran forever, and a ~1e12 degree value
|
||||
// took billions of iterations. NaN still passes through to clip() and is
|
||||
// returned as NaN, which is the same behaviour as before.
|
||||
if (!longitude.isFinite()) return longitude
|
||||
var result = longitude
|
||||
while (result < MIN_LONGITUDE) result += 360.0
|
||||
while (result > MAX_LONGITUDE) result -= 360.0
|
||||
result = ((result + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
// The closed interval [-180, 180] keeps +180 for a value that lands exactly
|
||||
// on the positive boundary (old loop: 180 stays 180, only > 180 wraps);
|
||||
// -180 is reserved for values that actually came from the west side.
|
||||
if (result == -180.0 && longitude > 0.0) result = 180.0
|
||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||
}
|
||||
|
||||
|
||||
+56
-4
@@ -74,8 +74,14 @@ fun qthToPosition(locator: String): GeoPos? {
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = longitude + 180
|
||||
val newLatitude = latitude + 90
|
||||
// The grid spans [0, 360) lon and [0, 180) lat once shifted. Clamping the
|
||||
// field index alone (coerceIn below) is not enough: at exactly +90 lat or
|
||||
// +180 lon the field saturates to R while the square/subsquare terms come
|
||||
// from a modulo that has already wrapped to 0, so the encoded locator
|
||||
// decoded back 10 degrees of latitude / 20 degrees of longitude away.
|
||||
// Nudge the upper bound into the last cell instead.
|
||||
val newLongitude = (longitude + 180).coerceIn(0.0, 360.0 - 1e-9)
|
||||
val newLatitude = (latitude + 90).coerceIn(0.0, 180.0 - 1e-9)
|
||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||
@@ -94,9 +100,55 @@ fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): Stri
|
||||
}
|
||||
|
||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
||||
return lat in -90.0..90.0 && lon in -180.0..180.0
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||
// Maidenhead fields are A-R (18 x 18). Subsquare letters are A-X (24).
|
||||
// Accepting S-X in the first pair decodes to latitude >90 / longitude >180.
|
||||
return locator.matches("[a-rA-R]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
||||
* A 4-char input is returned as-is when valid.
|
||||
*/
|
||||
fun qthToSquare(locator: String): String {
|
||||
val upper = locator.trim().uppercase()
|
||||
return when {
|
||||
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
||||
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
||||
else -> "----"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
||||
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
||||
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
||||
* Mirrors the neighbor logic decompiled from the QTH Locator app.
|
||||
*/
|
||||
fun qthNeighbors(square: String): List<String> {
|
||||
if (square.length != 4) return emptyList()
|
||||
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
||||
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
||||
val result = mutableListOf<String>()
|
||||
for (dLat in 1 downTo -1) { // north -> south
|
||||
for (dLon in -1..1) { // west -> east
|
||||
var lf = lonField
|
||||
var tf = latField
|
||||
var ls = lonSquare + dLon
|
||||
var ts = latSquare + dLat
|
||||
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
||||
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
||||
lf = (lf + 18) % 18
|
||||
tf = (tf + 18) % 18
|
||||
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
|
||||
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
interface ILotwSatellitesRepo {
|
||||
sealed class RefreshResult {
|
||||
data class Ok(val count: Int) : RefreshResult()
|
||||
data class Error(val message: String) : RefreshResult()
|
||||
}
|
||||
|
||||
fun restore()
|
||||
|
||||
suspend fun refresh(): RefreshResult
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
|
||||
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
|
||||
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
|
||||
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
object LotwSatellites {
|
||||
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
|
||||
|
||||
@Volatile
|
||||
private var dynamicNames: Set<String> = emptySet()
|
||||
|
||||
/** Effective satellite names: static embedded set union runtime updates */
|
||||
val names: Set<String>
|
||||
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
|
||||
|
||||
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
|
||||
fun updateNames(newNames: Set<String>) {
|
||||
if (newNames.isNotEmpty()) dynamicNames = newNames
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
|
||||
*
|
||||
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
|
||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
|
||||
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
|
||||
* Strategy: try v2 first, auto-fallback to v1 on 404.
|
||||
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
|
||||
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
|
||||
/** Station info (GET /api/v2/station/{id} result) */
|
||||
data class WavelogStation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val gridsquare: String
|
||||
)
|
||||
|
||||
sealed class WavelogResult {
|
||||
data class Success(val message: String) : WavelogResult()
|
||||
data class Failure(val message: String) : WavelogResult()
|
||||
}
|
||||
|
||||
object WaveLogApi {
|
||||
|
||||
private const val TIMEOUT_MS = 15000
|
||||
|
||||
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
|
||||
fun normalizeUrl(raw: String): String {
|
||||
var u = raw.trim().trimEnd('/')
|
||||
if (u.isBlank()) return ""
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||
return u
|
||||
}
|
||||
|
||||
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
// v2: GET /index.php/api/v2/token
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||
|
||||
// v1: POST /index.php/api/get_contacts_adif (key in body)
|
||||
if (stationId.isNotBlank()) {
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
}
|
||||
// v1 attempt without index.php
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
|
||||
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||
}
|
||||
|
||||
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||
if (code in 200..299) {
|
||||
return@withContext try {
|
||||
val obj = JSONObject(resp)
|
||||
val data = obj.optJSONObject("data") ?: obj
|
||||
val station = WavelogStation(
|
||||
id = data.optInt("id"),
|
||||
name = data.optString("name"),
|
||||
callsign = data.optString("callsign"),
|
||||
gridsquare = data.optString("gridsquare")
|
||||
)
|
||||
WavelogResult.Success(JSONObject().apply {
|
||||
put("id", station.id); put("name", station.name)
|
||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
WavelogResult.Failure("解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
|
||||
WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
|
||||
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
|
||||
* that fails to parse an illegal band falls back to a default such as
|
||||
* 160m. Satellite QSOs must carry the real band of the TX frequency.
|
||||
*/
|
||||
fun bandFromHz(freqHz: Long): String = when {
|
||||
freqHz >= 1240_000_000 -> "23CM"
|
||||
freqHz >= 902_000_000 -> "33CM"
|
||||
freqHz >= 420_000_000 -> "70CM"
|
||||
freqHz >= 222_000_000 -> "1.25M"
|
||||
freqHz >= 144_000_000 -> "2M"
|
||||
freqHz >= 50_000_000 -> "6M"
|
||||
freqHz >= 28_000_000 -> "10M"
|
||||
freqHz >= 24_890_000 -> "12M"
|
||||
freqHz >= 21_000_000 -> "15M"
|
||||
freqHz >= 18_068_000 -> "17M"
|
||||
freqHz >= 14_000_000 -> "20M"
|
||||
freqHz >= 10_000_000 -> "30M"
|
||||
freqHz >= 7_000_000 -> "40M"
|
||||
freqHz >= 5_102_000 -> "60M"
|
||||
freqHz >= 3_500_000 -> "80M"
|
||||
freqHz >= 1_800_000 -> "160M"
|
||||
else -> "160M"
|
||||
}
|
||||
|
||||
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
|
||||
private fun bandLetter(freqHz: Long): String = when {
|
||||
freqHz >= 1_240_000_000 -> "S"
|
||||
freqHz >= 420_000_000 -> "U"
|
||||
freqHz >= 144_000_000 -> "V"
|
||||
else -> "V"
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
|
||||
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
|
||||
*/
|
||||
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
|
||||
if (rxFreqHz <= 0) return ""
|
||||
val up = bandLetter(txFreqHz)
|
||||
val down = bandLetter(rxFreqHz)
|
||||
return if (up == down) "" else "$up/$down"
|
||||
}
|
||||
|
||||
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
.ifBlank { raw.trim() }
|
||||
.uppercase(Locale.ENGLISH)
|
||||
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
|
||||
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
|
||||
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
|
||||
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
|
||||
val commonName = mapOf(
|
||||
"ZARYA" to "ARISS",
|
||||
"ARISS" to "ARISS",
|
||||
"FUNCUBE-1" to "AO-73",
|
||||
"DIWATA-2B" to "PO-101",
|
||||
"SAUDISAT-1C" to "SO-50",
|
||||
"SAUDISAT 1C" to "SO-50",
|
||||
"DIWATA-2A" to "PO-101"
|
||||
)
|
||||
val candidate = commonName[main] ?: main
|
||||
return if (candidate in LotwSatellites.names) candidate else main
|
||||
}
|
||||
|
||||
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||
suspend fun postQso(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stationProfileId: String,
|
||||
qso: WavelogQso,
|
||||
gridsquare: String
|
||||
): WavelogResult = withContext(Dispatchers.IO) {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
val satName = normalizeSatName(qso.satName)
|
||||
|
||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
val v2Body = JSONObject().apply {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||
put("gridsquare", gridsquare)
|
||||
put("rst_sent", "59")
|
||||
put("rst_rcvd", "59")
|
||||
put("sat_name", satName)
|
||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||
}
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||
|
||||
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||
val v1Body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("station_profile_id", stationProfileId)
|
||||
put("type", "adif")
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
// v1 without index.php
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||
|
||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||
}
|
||||
|
||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
}
|
||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
append(field("rst_sent", "59"))
|
||||
append(field("rst_rcvd", "59"))
|
||||
// Send the grid at full precision. Truncating to 4 characters threw
|
||||
// away the 6-character locator the QRZ lookup provides, coarsening the
|
||||
// stored position from ~4.6 km to ~100 km and making a QSO logged via
|
||||
// v1 disagree with the same QSO logged via v2 (which sends it whole).
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
}
|
||||
}
|
||||
|
||||
/** Generic HTTP request (returns code + body) */
|
||||
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||
return try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
if (jsonBody != null) {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val body = if (stream != null) {
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||
} else ""
|
||||
code to body
|
||||
} catch (e: Exception) {
|
||||
-1 to (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortError(body: String): String {
|
||||
if (body.startsWith("<")) return body.take(80) // HTML error page
|
||||
return try {
|
||||
val obj = JSONObject(body)
|
||||
val err = obj.optJSONObject("error")
|
||||
err?.optString("message")?.ifBlank { body.take(120) }
|
||||
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcDate(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d-%02d-%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTime(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d:%02d:%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcDateCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
}
|
||||
|
||||
private fun utcTimeCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
|
||||
*
|
||||
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
|
||||
* implemented with SharedPreferences in core/data.
|
||||
* Queue capped at 500 entries (oldest dropped beyond that).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Storage abstraction (SharedPreferences impl lives in core/data) */
|
||||
interface IWavelogQueueStore {
|
||||
fun load(): String
|
||||
fun save(json: String)
|
||||
}
|
||||
|
||||
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
|
||||
data class WavelogQso(
|
||||
val id: String, // 本地唯一 id(UUID)
|
||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||
val call: String,
|
||||
val mode: String,
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
)
|
||||
|
||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
|
||||
private val key = "wavelog_queue"
|
||||
|
||||
fun all(): List<WavelogQso> {
|
||||
val raw = store.load()
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
WavelogQso(
|
||||
id = o.getString("id"),
|
||||
timeUtcMs = o.getLong("timeUtcMs"),
|
||||
call = o.optString("call"),
|
||||
mode = o.optString("mode"),
|
||||
freqTxHz = o.optLong("freqTxHz"),
|
||||
freqRxHz = o.optLong("freqRxHz"),
|
||||
satName = o.optString("satName"),
|
||||
sessionId = o.optString("sessionId"),
|
||||
gridsquare = o.optString("gridsquare"),
|
||||
uploaded = o.optBoolean("uploaded", false)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(qso: WavelogQso) {
|
||||
val list = all().toMutableList()
|
||||
list.add(0, qso) // 最新在前
|
||||
if (list.size > 500) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(id: String) {
|
||||
save(all().filter { it.id != id })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeAll(ids: Set<String>) {
|
||||
save(all().filter { it.id !in ids })
|
||||
}
|
||||
|
||||
/** Mark as uploaded (kept in the queue; checkmark in the table) */
|
||||
@Synchronized
|
||||
fun markUploaded(id: String) {
|
||||
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||
}
|
||||
|
||||
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||
@Synchronized
|
||||
fun updateGridsquare(id: String, grid: String) {
|
||||
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
|
||||
}
|
||||
|
||||
/** Remove all uploaded entries (optional; keeps the queue lean) */
|
||||
@Synchronized
|
||||
fun removeUploaded() {
|
||||
save(all().filter { !it.uploaded })
|
||||
}
|
||||
|
||||
private fun save(list: List<WavelogQso>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { q ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("sessionId", q.sessionId)
|
||||
put("gridsquare", q.gridsquare)
|
||||
put("uploaded", q.uploaded)
|
||||
})
|
||||
}
|
||||
store.save(arr.toString())
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
|
||||
*
|
||||
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
|
||||
* -> POST /api/v2/qso -> success removes from the queue.
|
||||
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
|
||||
* retrying the batch with force=true once confirmed.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import org.json.JSONObject
|
||||
|
||||
sealed class UploadOutcome {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
data class Done(
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val message: String,
|
||||
val firstError: String = ""
|
||||
) : UploadOutcome()
|
||||
}
|
||||
|
||||
class WavelogUploader(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val queue: WavelogQueue
|
||||
) {
|
||||
|
||||
// Station grid cache (refreshed before each upload; stale value kept on failure)
|
||||
private var cachedStationGrid: String? = null
|
||||
|
||||
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
|
||||
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
||||
val settings = settingsRepo.otherSettings.value
|
||||
val url = settings.wavelogUrl
|
||||
val apiKey = settings.wavelogApiKey
|
||||
val stationId = settings.wavelogStationId
|
||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||
}
|
||||
|
||||
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||
if (stationGrid.isNullOrBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||
}
|
||||
|
||||
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
|
||||
if (!force) {
|
||||
val userGrid = userQthGrid()
|
||||
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||
val entries = queue.all()
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
var firstError = ""
|
||||
for (qso in entries) {
|
||||
if (qso.uploaded) { ok++; continue }
|
||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||
if (result is WavelogResult.Success) {
|
||||
ok++
|
||||
queue.markUploaded(qso.id)
|
||||
} else {
|
||||
fail++
|
||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||
}
|
||||
}
|
||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||
}
|
||||
|
||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||
if (result is WavelogResult.Success) {
|
||||
return try {
|
||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||
?: cachedStationGrid
|
||||
} catch (_: Exception) { cachedStationGrid }
|
||||
}
|
||||
return cachedStationGrid
|
||||
}
|
||||
|
||||
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
|
||||
private fun userQthGrid(): String? {
|
||||
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -111,6 +113,47 @@ class DataParserTest {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
}
|
||||
|
||||
private fun csvWithEpoch(epoch: String) = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
// Regression: the day fraction used to be built by string surgery
|
||||
// (Double.toString().substring(1)), but toString switches to scientific
|
||||
// notation below 1e-3, so the leading significant digit was truncated.
|
||||
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
|
||||
// roughly 26 years off, with no exception to reveal it.
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
|
||||
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
@@ -239,4 +282,10 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
+168
-15
@@ -3,22 +3,30 @@ package com.rtbishop.look4sat.core.domain
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
private fun linearTransponder(
|
||||
uuid: String = "linear",
|
||||
upLow: Long = 145_000_000L,
|
||||
upHigh: Long = 145_500_000L,
|
||||
downLow: Long = 435_000_000L,
|
||||
downHigh: Long? = 435_500_000L,
|
||||
inverted: Boolean = false
|
||||
inverted: Boolean = false,
|
||||
info: String = "Linear Transponder",
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
)
|
||||
|
||||
private fun fmTransponder() = SatRadio(
|
||||
@@ -48,14 +56,116 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||
// AO-7's Mode A: same range, SSB and CW entries
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||
downlinkMode = "USB", uplinkMode = "USB"
|
||||
)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||
downlinkMode = "CW", uplinkMode = "CW"
|
||||
)
|
||||
val modeB = linearTransponder(
|
||||
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||
downlinkMode = "USB", uplinkMode = "LSB"
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||
assertEquals(2, result.size)
|
||||
// SSB entry should be preferred over CW (same range)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
assertEquals("modeb-uuid", result[1].uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||
downlinkMode = "CW", uplinkMode = "CW",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||
)
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||
downlinkMode = "USB", uplinkMode = "LSB",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||
inverted = true
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
// Verify the correct invert flag is preserved
|
||||
assertTrue(result[0].isInverted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertTrue(uplink!! > 0)
|
||||
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@@ -70,42 +180,86 @@ class DopplerFrequencyCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0) // ~7 km/s receding
|
||||
val orbitalPos = pos(7.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
||||
assertTrue(uplink!! > 145_200_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
||||
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(435_202_500L, downlink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = 435_202_500L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = 2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
uplinkHz = 145_200_000L,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(435_197_500L, downlink)
|
||||
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
downlinkHz = downlink!!,
|
||||
transponder = xpdr,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetHz = -2_500L
|
||||
)
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
// Inverted: offset from high end → maps to high end of uplink
|
||||
assertEquals(145_300_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_roundTrip() {
|
||||
// downlink → uplink → downlink should round-trip
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(3.5)
|
||||
val originalDownlink = 435_250_000L
|
||||
@@ -113,7 +267,6 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertNotNull(uplink)
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
// Doppler round-trip: small residual due to freq-dependent Doppler
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||
import org.junit.Test
|
||||
|
||||
class QthConverterTest {
|
||||
@@ -68,7 +70,9 @@ class QthConverterTest {
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
||||
// Exact positive bounds belong to the final cell, not a modulo-wrapped
|
||||
// R-field/0-square combination that decodes 10°/20° away.
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
@@ -82,4 +86,91 @@ class QthConverterTest {
|
||||
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Encoded locator always decodes back within one cell`() {
|
||||
// An 8-char cell is 30" lon x 15" lat, so a correct encode/decode pair
|
||||
// can never differ by more than that. Field clamping used to break this
|
||||
// near +90 / +180 and produced errors up to 10 deg lat / 20 deg lon.
|
||||
var worstLat = 0.0
|
||||
var worstLon = 0.0
|
||||
var worst = ""
|
||||
var lat = -90.0
|
||||
while (lat <= 90.0) {
|
||||
var lon = -180.0
|
||||
while (lon <= 180.0) {
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
?: error("valid position rejected: ($lat, $lon)")
|
||||
val pos = qthToPosition(qth) ?: error("own output rejected: $qth")
|
||||
val dLat = kotlin.math.abs(pos.latitude - lat)
|
||||
val dLon = kotlin.math.abs(pos.longitude - lon)
|
||||
if (dLat > worstLat || dLon > worstLon) {
|
||||
worstLat = maxOf(worstLat, dLat)
|
||||
worstLon = maxOf(worstLon, dLon)
|
||||
worst = "($lat, $lon) -> $qth -> (${pos.latitude}, ${pos.longitude})"
|
||||
}
|
||||
lon += 0.5
|
||||
}
|
||||
lat += 0.5
|
||||
}
|
||||
assert(worstLat <= 0.01 && worstLon <= 0.01) {
|
||||
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given out of range longitude returns null`() {
|
||||
// Maidenhead only covers -180..180; 181..360 used to be accepted and
|
||||
// encoded into a plausible-looking locator 20-200 deg away.
|
||||
assert(positionToQth(0.0, 181.0) == null)
|
||||
assert(positionToQth(0.0, 270.0) == null)
|
||||
assert(positionToQth(0.0, 360.0) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given locator with out of range field returns null`() {
|
||||
// Fields run A-R; S-X in the first pair decoded past the poles.
|
||||
assert(qthToPosition("SS00aa") == null)
|
||||
assert(qthToPosition("XX99xx") == null)
|
||||
assert(qthToPosition("AS00aa") == null)
|
||||
assert(qthToPosition("AX99xx") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given square returns correct 3x3 neighbors`() {
|
||||
// Reference grid from the QTH Locator screenshot: OL42
|
||||
val neighbors = qthNeighbors("OL42")
|
||||
assert(neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
)) { "OL42 grid mismatch: $neighbors" }
|
||||
// Center cell must be the input itself
|
||||
assert(neighbors[4] == "OL42")
|
||||
// 9 cells, all distinct
|
||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary square wraps fields correctly`() {
|
||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||
val sw = qthNeighbors("AA00")
|
||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
// North-east corner: RR99 wraps to AA00
|
||||
val ne = qthNeighbors("RR99")
|
||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
// Field boundary: IO91's east neighbors cross into J field
|
||||
val london = qthNeighbors("IO91")
|
||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given full locator returns square part`() {
|
||||
assert(qthToSquare("OL42ih45") == "OL42")
|
||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||
assert(qthToSquare("JN58") == "JN58")
|
||||
assert(qthToSquare("garbage!!") == "----")
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
|
||||
* course/speed extensions with the JVM default locale produced Eastern Arabic
|
||||
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
|
||||
* packets.
|
||||
*
|
||||
* Regression guard: every formatted field must stay ASCII regardless of the
|
||||
* default locale.
|
||||
*/
|
||||
class AprsPacketLocaleTest {
|
||||
|
||||
private val original: Locale = Locale.getDefault()
|
||||
|
||||
@After
|
||||
fun restoreLocale() {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
|
||||
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
|
||||
|
||||
@Test
|
||||
fun position_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertEquals("3954.25N/11624.44E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun position_staysAsciiUnderBengaliLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
|
||||
|
||||
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertEquals("3352.13S/15112.56E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
|
||||
|
||||
val altitude = AprsPacket.formatAltitude(100.0)
|
||||
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
|
||||
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
|
||||
|
||||
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
|
||||
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
|
||||
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
|
||||
assertEquals("/A=000328", altitude)
|
||||
assertEquals("/090/019", courseSpeed)
|
||||
assertEquals("r/39.904/116.407/100", filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun altitude_clampsNegativeToKeepSixDigitField() {
|
||||
// "%06d" of a negative value yields "/A=-00164": the '-' takes a digit
|
||||
// slot, so the extension is no longer a valid fixed-width field.
|
||||
assertEquals("/A=000000", AprsPacket.formatAltitude(-50.0))
|
||||
assertEquals("/A=000000", AprsPacket.formatAltitude(-1.0))
|
||||
assertEquals("/A=000328", AprsPacket.formatAltitude(100.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun courseSpeed_wrapsCourseIntoValidRange() {
|
||||
assertEquals("/000/019", AprsPacket.formatCourseSpeed(10.0, 360f))
|
||||
assertEquals("/359/019", AprsPacket.formatCourseSpeed(10.0, -1f))
|
||||
assertEquals("/090/019", AprsPacket.formatCourseSpeed(10.0, 90f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
|
||||
for (ambiguity in 1..4) {
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
|
||||
.toUncompressedString()
|
||||
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Greedy CTC collapse, matching the reference implementation's
|
||||
* `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
|
||||
*
|
||||
* Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
|
||||
*/
|
||||
class CwCtcDecoderTest {
|
||||
|
||||
private val chars = listOf(
|
||||
",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
|
||||
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
|
||||
)
|
||||
private val blank = 41
|
||||
|
||||
/** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
|
||||
private fun logits(path: IntArray): Array<Array<FloatArray>> {
|
||||
val frames = Array(path.size) { t ->
|
||||
FloatArray(42) { -10f }.also { it[path[t]] = 0f }
|
||||
}
|
||||
return arrayOf(frames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alphabetSizeMatchesModelMetadata() {
|
||||
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_dropsRunsOfTheSameLabel() {
|
||||
assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_keepsRepeatsSeparatedByBlank() {
|
||||
// A A <blank> A collapses to "AA": the blank breaks the run.
|
||||
assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_allBlanksYieldEmptyString() {
|
||||
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_emptyInputYieldsEmptyString() {
|
||||
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_decodesCallsignWithSpaceAndDigits() {
|
||||
// "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
|
||||
val path = intArrayOf(
|
||||
blank, 16, 16, blank, 30, blank, 40,
|
||||
15, blank, 20, blank, 10, blank
|
||||
)
|
||||
assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun greedy_picksHighestScoringClassPerFrame() {
|
||||
// Frame favours S (32) over T (33); only S must survive.
|
||||
val frame = FloatArray(42) { -10f }
|
||||
frame[33] = -1f
|
||||
frame[32] = -0.1f
|
||||
assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
class CwDecoderTest {
|
||||
|
||||
// --- Morse table ---
|
||||
|
||||
@Test
|
||||
fun morseToChar_basicLetters() {
|
||||
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
|
||||
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
|
||||
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_numbers() {
|
||||
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
|
||||
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_unknown_returnsNull() {
|
||||
assertNull(CwBayesianDecoder.morseToChar("......."))
|
||||
assertNull(CwBayesianDecoder.morseToChar(""))
|
||||
}
|
||||
|
||||
// --- FFT ---
|
||||
|
||||
@Test
|
||||
fun fft_magnitudeSpectrum_detectsTone() {
|
||||
val fft = CwFFT(256)
|
||||
val sampleRate = 8000f
|
||||
val freq = 700f
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
|
||||
val mag = fft.magnitudeSpectrum(buffer)
|
||||
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
|
||||
var maxBin = 0
|
||||
var maxVal = 0f
|
||||
for (i in mag.indices) {
|
||||
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
|
||||
}
|
||||
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
|
||||
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fft_magnitudeSpectrum_silence_isFlat() {
|
||||
val fft = CwFFT(256)
|
||||
val buffer = FloatArray(256) { 0f }
|
||||
val mag = fft.magnitudeSpectrum(buffer)
|
||||
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fft_rejectsWrongSize() {
|
||||
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
|
||||
}
|
||||
|
||||
// --- Spectrogram ---
|
||||
|
||||
@Test
|
||||
fun spectrogram_addSamples_updatesEnergy() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val freq = 700f
|
||||
// Feed multiple frames to stabilize energy normalization
|
||||
for (i in 0..5) {
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
|
||||
spec.addSamples(buffer)
|
||||
}
|
||||
val col = spec.getCurrentColumn()
|
||||
val peakBin = spec.findPeakBin()
|
||||
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_findPeakBin_returnsValidBin() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
// Add multiple frames of 700 Hz tone
|
||||
for (i in 0..5) {
|
||||
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||
spec.addSamples(buffer)
|
||||
}
|
||||
val peakBin = spec.findPeakBin()
|
||||
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_freqToBin_roundtrip() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val freq = 700f
|
||||
val bin = spec.freqToBin(freq)
|
||||
val backFreq = spec.binToFreq(bin)
|
||||
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_getBinEnergy_returnsCorrectLength() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val energy = spec.getBinEnergy(0, 10)
|
||||
assertEquals(10, energy.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogram_reset() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
spec.addSamples(FloatArray(256) { 1f })
|
||||
spec.reset()
|
||||
assertEquals(-1, spec.findPeakBin())
|
||||
}
|
||||
|
||||
// --- Bayesian decoder ---
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_dit() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// At 20 WPM, dot = 60 ms
|
||||
val result = decoder.processTone(60f)
|
||||
assertEquals('0', result.symbol)
|
||||
assertTrue("Dit probability should be positive", result.probability > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_dash() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Dash = 3 * dot = 180 ms
|
||||
val result = decoder.processTone(180f)
|
||||
assertEquals('1', result.symbol)
|
||||
assertTrue("Dash probability should be positive", result.probability > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processTone_unknown_returnsNull() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Very long tone — low probability for both dit and dash
|
||||
val result = decoder.processTone(5000f)
|
||||
assertNull(result.symbol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processGap_interChar_returnsChar() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit
|
||||
decoder.processTone(60f) // dit
|
||||
decoder.processTone(60f) // dit
|
||||
// 3 dots = "000" = 'S'
|
||||
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
|
||||
assertEquals('S', char)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_processGap_wordGap_addsSpace() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit = 'E'
|
||||
decoder.processGap(180f) // inter-char gap
|
||||
// Now word gap
|
||||
val space = decoder.processGap(420f) // 7 * dot
|
||||
assertEquals(' ', space)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_decodedText_accumulates() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f) // dit = 'E'
|
||||
decoder.processGap(180f) // inter-char
|
||||
assertTrue(decoder.decodedText.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_reset() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
decoder.processTone(60f)
|
||||
decoder.reset()
|
||||
assertEquals("", decoder.decodedText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bayesian_getSpeed() {
|
||||
val decoder = CwBayesianDecoder()
|
||||
// Send 3 dits at 20 WPM (60 ms each)
|
||||
decoder.processTone(60f)
|
||||
decoder.processTone(60f)
|
||||
decoder.processTone(60f)
|
||||
val speed = decoder.getSpeed()
|
||||
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
|
||||
}
|
||||
|
||||
// --- Channel tracker ---
|
||||
|
||||
@Test
|
||||
fun channelTracker_initialState() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
val tracker = CwChannelTracker(spec)
|
||||
val channels = tracker.update()
|
||||
assertTrue("No channels should be active initially", channels.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_detectsTone() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
// Feed a tone
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
val channels = tracker.update()
|
||||
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_bestChannel() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
tracker.update()
|
||||
val best = tracker.getBestChannel()
|
||||
assertNotNull("Best channel should exist", best)
|
||||
if (best != null) assertTrue(best.frequency in 600f..800f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelTracker_reset() {
|
||||
val spec = CwSpectrogram(sampleRate = 8000)
|
||||
for (i in 0..5) {
|
||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
val tracker = CwChannelTracker(spec)
|
||||
tracker.update()
|
||||
tracker.reset()
|
||||
assertNull(tracker.getBestChannel())
|
||||
}
|
||||
|
||||
// --- Full decoder ---
|
||||
|
||||
@Test
|
||||
fun decoder_initialState() {
|
||||
val decoder = CwDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processSilence_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { 0f })
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processNoise_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
|
||||
assertNotNull(decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_processTone_doesNotCrash() {
|
||||
val decoder = CwDecoder()
|
||||
for (i in 0..20) {
|
||||
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
}
|
||||
assertNotNull(decoder.decodedTextFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_reset() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(256) { 1f })
|
||||
decoder.resetDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoder_withFixedPitch() {
|
||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||
assertEquals(700f, decoder.estimatedPitch.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rolling audio buffer feeding DeepCW.
|
||||
*
|
||||
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
|
||||
* rewrites earlier output whenever more context arrives, so incremental
|
||||
* stitching is impossible. Instead we keep a bounded window and re-decode all
|
||||
* of it periodically, replacing the displayed text.
|
||||
*/
|
||||
class CwDeepBufferTest {
|
||||
|
||||
@Test
|
||||
fun capacityIsCappedAtMaxSeconds() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
|
||||
repeat(30) { buffer.append(FloatArray(3200)) }
|
||||
assertEquals(3200 * 20, buffer.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldestSamplesAreDiscardedFirst() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f))
|
||||
buffer.append(floatArrayOf(4f, 5f))
|
||||
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotIsChronologicalAfterWrapAround() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
|
||||
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendLargerThanCapacityKeepsOnlyTheTail() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
|
||||
assertEquals(4, buffer.size)
|
||||
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redecodeIsSignalledOncePerInterval() {
|
||||
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
|
||||
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redecodeIntervalDoesNotDriftOverManyChunks() {
|
||||
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
|
||||
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
var fired = 0
|
||||
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
|
||||
assertEquals(10, fired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotDoesNotAliasInternalStorage() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.snapshot()[0] = 99f
|
||||
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsSamplesAndIntervalCounter() {
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
buffer.append(FloatArray(3200))
|
||||
buffer.reset()
|
||||
assertEquals(0, buffer.size)
|
||||
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasEnoughAudioTracksTheModelMinimum() {
|
||||
// compute() needs at least FFT_LENGTH samples to produce one frame.
|
||||
val buffer = CwDeepBuffer(3200, 20.0)
|
||||
buffer.append(FloatArray(100))
|
||||
assertFalse(buffer.hasEnoughAudio)
|
||||
buffer.append(FloatArray(200))
|
||||
assertTrue(buffer.hasEnoughAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsMatchTheMeasuredOptimum() {
|
||||
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
|
||||
// reaching 0.0% CER, and keeps inference well inside real time.
|
||||
val buffer = CwDeepBuffer()
|
||||
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overflowCollectsEvictedSamplesInOrder() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
|
||||
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
|
||||
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
|
||||
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drainOverflowClearsItself() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.append(floatArrayOf(5f))
|
||||
assertEquals(1, buffer.overflowCount)
|
||||
buffer.drainOverflow()
|
||||
assertEquals(0, buffer.overflowCount)
|
||||
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsOverflow() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.append(floatArrayOf(5f))
|
||||
assertEquals(1, buffer.overflowCount)
|
||||
buffer.reset()
|
||||
assertEquals(0, buffer.overflowCount)
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Pins the Kotlin front-end to the upstream Python reference implementation.
|
||||
*
|
||||
* `golden_spec.txt` was produced by deepcw-engine's own preprocessing code
|
||||
* (numpy reflect padding, `np.hanning(N+1)[:-1]`, `np.fft.rfft`, `log1p`) over
|
||||
* the audio [generateTestAudio] builds. Both sides synthesise the audio from
|
||||
* the same deterministic formula, so only the spectrogram needs pinning.
|
||||
*
|
||||
* A mismatch here means the model would receive subtly wrong input and emit
|
||||
* plausible-looking garbage, which is very hard to diagnose downstream — so
|
||||
* this test guards the whole pipeline.
|
||||
*
|
||||
* Regenerate with `scripts/deepcw_gen_golden.py` from the skill library if the
|
||||
* model metadata ever changes.
|
||||
*/
|
||||
class CwDeepGoldenVectorTest {
|
||||
|
||||
private companion object {
|
||||
const val SRC_RATE = 8000
|
||||
const val TONE_HZ = 700.0
|
||||
const val AMPLITUDE = 0.6
|
||||
const val DOT_SAMPLES = 480 // 20 WPM at 8000 Hz: 1.2/20*8000
|
||||
const val PATTERN = "-.-." // the letter C
|
||||
const val TOLERANCE = 1e-4f
|
||||
}
|
||||
|
||||
/** Square-keyed 700 Hz tone: 4 dots of silence, "C", 4 dots of silence. */
|
||||
private fun generateTestAudio(): FloatArray {
|
||||
val keying = ArrayList<Int>()
|
||||
repeat(4 * DOT_SAMPLES) { keying.add(0) }
|
||||
for ((i, element) in PATTERN.withIndex()) {
|
||||
val length = if (element == '-') 3 * DOT_SAMPLES else DOT_SAMPLES
|
||||
repeat(length) { keying.add(1) }
|
||||
if (i < PATTERN.length - 1) repeat(DOT_SAMPLES) { keying.add(0) }
|
||||
}
|
||||
repeat(4 * DOT_SAMPLES) { keying.add(0) }
|
||||
|
||||
return FloatArray(keying.size) { i ->
|
||||
if (keying[i] == 1) {
|
||||
(AMPLITUDE * sin(2.0 * PI * TONE_HZ * i / SRC_RATE)).toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readGoldenSpectrogram(): Array<FloatArray> {
|
||||
val stream = javaClass.classLoader?.getResourceAsStream("cw/golden_spec.txt")
|
||||
?: throw IllegalStateException("cw/golden_spec.txt missing from test resources")
|
||||
stream.bufferedReader().use { reader ->
|
||||
val (frames, bins) = reader.readLine().trim().split(" ").map(String::toInt)
|
||||
return Array(frames) {
|
||||
val row = reader.readLine().trim().split(" ")
|
||||
require(row.size == bins) { "expected $bins values, got ${row.size}" }
|
||||
FloatArray(bins) { i -> row[i].toFloat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spectrogramMatchesPythonReferenceFrameByFrame() {
|
||||
val expected = readGoldenSpectrogram()
|
||||
val audio = CwDeepSpectrogram.resampleLinear(
|
||||
generateTestAudio(), SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val actual = CwDeepSpectrogram.compute(audio)
|
||||
|
||||
assertEquals("frame count", expected.size, actual.size)
|
||||
assertEquals("bin count", expected[0].size, actual[0].size)
|
||||
|
||||
var worstDelta = 0f
|
||||
var worstAt = ""
|
||||
for (t in expected.indices) {
|
||||
for (f in expected[t].indices) {
|
||||
val delta = abs(expected[t][f] - actual[t][f])
|
||||
if (delta > worstDelta) {
|
||||
worstDelta = delta
|
||||
worstAt = "frame $t bin $f: expected ${expected[t][f]}, got ${actual[t][f]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
"front-end diverges from the Python reference — worst delta $worstDelta at $worstAt",
|
||||
worstDelta <= TOLERANCE
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resampledLengthMatchesReference() {
|
||||
val audio = generateTestAudio()
|
||||
assertEquals("source audio length", 9120, audio.size)
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
assertEquals("resampled length", 3648, resampled.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goldenVectorHasExpectedShape() {
|
||||
val golden = readGoldenSpectrogram()
|
||||
assertEquals("frames", 77, golden.size)
|
||||
assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size)
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Verifies the DeepCW front-end against the upstream Python reference
|
||||
* implementation (deepcw-engine examples/python/decode_morse.py).
|
||||
*
|
||||
* Model metadata: sampleRate 3200, fftLength 256, hopLength 48,
|
||||
* 400-1200 Hz -> 65 bins, log1p normalization.
|
||||
*/
|
||||
class CwDeepSpectrogramTest {
|
||||
|
||||
@Test
|
||||
fun frequencyBinRange_matchesModelMetadata() {
|
||||
// binHz = 3200/256 = 12.5; start = ceil(400/12.5) = 32; stop = floor(1200/12.5)+1 = 97
|
||||
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
|
||||
assertEquals(32, start)
|
||||
assertEquals(97, stop)
|
||||
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compute_producesTimeBy65Matrix() {
|
||||
// 1 second at 3200 Hz. Reflect padding adds fft/2 on both sides,
|
||||
// so frames = 1 + (3200 + 256 - 256)/48 = 1 + 66 = 67
|
||||
val spec = CwDeepSpectrogram.compute(FloatArray(3200))
|
||||
assertEquals(67, spec.size)
|
||||
assertEquals(65, spec[0].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compute_toneLandsInExpectedBin() {
|
||||
// 700 Hz -> absolute bin 700/12.5 = 56 -> relative index 56 - 32 = 24
|
||||
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
|
||||
val spec = CwDeepSpectrogram.compute(audio)
|
||||
val middle = spec[spec.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The waterfall asks for the whole band so that a tone the model cannot read is still
|
||||
* in the picture. Inside the model's window such a tone leaves nothing to see: the
|
||||
* brightest column there is noise, and it does not even follow the keying.
|
||||
*/
|
||||
@Test
|
||||
fun compute_wholeBandPlacesAnOutOfWindowTone() {
|
||||
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 1500.0 * it / 3200.0)).toFloat() }
|
||||
val display = CwDeepSpectrogram.compute(
|
||||
audio,
|
||||
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
|
||||
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
|
||||
)
|
||||
// DC to Nyquist inclusive: 0..1600 Hz in 12.5 Hz steps.
|
||||
assertEquals(129, display[0].size)
|
||||
|
||||
val middle = display[display.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz)
|
||||
}
|
||||
|
||||
/** The model's own call must keep its exact shape, whatever the display asks for. */
|
||||
@Test
|
||||
fun compute_defaultsToTheModelWindow() {
|
||||
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
|
||||
val model = CwDeepSpectrogram.compute(audio)
|
||||
val explicit = CwDeepSpectrogram.compute(
|
||||
audio, CwDeepSpectrogram.MIN_FREQ_HZ, CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
)
|
||||
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
|
||||
assertEquals(model.size, explicit.size)
|
||||
for (frame in model.indices) {
|
||||
assertArrayEquals(
|
||||
"explicit model range must equal the default",
|
||||
model[frame], explicit[frame], 0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compute_appliesLog1pSoValuesAreNonNegative() {
|
||||
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
|
||||
val spec = CwDeepSpectrogram.compute(audio)
|
||||
for (frame in spec) {
|
||||
for (v in frame) {
|
||||
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resampleLinear_convertsRateAndLength() {
|
||||
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(8000), 8000, 3200).size)
|
||||
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(44100), 44100, 3200).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resampleLinear_sameRateIsIdentity() {
|
||||
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
|
||||
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(0.2f, out[1], 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resampleLinear_preservesToneFrequency() {
|
||||
// A 700 Hz tone sampled at 8000 Hz must still peak at bin 24 after
|
||||
// resampling to 3200 Hz — this is the path real microphone audio takes.
|
||||
val at8k = FloatArray(8000) { (0.6 * sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||
val at3200 = CwDeepSpectrogram.resampleLinear(at8k, 8000, 3200)
|
||||
val spec = CwDeepSpectrogram.compute(at3200)
|
||||
val middle = spec[spec.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compute_rejectsAudioShorterThanFftLength() {
|
||||
try {
|
||||
CwDeepSpectrogram.compute(FloatArray(100))
|
||||
throw AssertionError("expected an exception for audio shorter than fftLength")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// desired path
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
|
||||
* samples it hands over must be the most recent audio in chronological order. Getting
|
||||
* the ring wrap wrong would splice the waveform and corrupt every pitch estimate
|
||||
* silently - no downstream assertion would notice, which is why these tests drive the
|
||||
* real class rather than restating its logic.
|
||||
*/
|
||||
class CwDetectionPoolTest {
|
||||
|
||||
private val capacity = 1280
|
||||
|
||||
/** Chunk of a monotonic ramp, so any reordering is visible. */
|
||||
private fun ramp(from: Int, count: Int) = FloatArray(count) { (from + it).toFloat() }
|
||||
|
||||
private fun assertAscending(values: FloatArray) {
|
||||
for (i in 1 until values.size) {
|
||||
assertEquals(
|
||||
"sample $i breaks the ramp, so the ring wrap is wrong",
|
||||
values[i - 1] + 1f, values[i], 0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports readiness only once capacity is reached`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
assertFalse("an empty pool is not ready", pool.isReady)
|
||||
assertEquals(0, pool.size)
|
||||
|
||||
// Three 320-sample chunks are 960 samples: still short.
|
||||
repeat(3) { pool.add(ramp(it * 320, 320)) }
|
||||
assertEquals(960, pool.size)
|
||||
assertFalse("960 of $capacity samples is not ready", pool.isReady)
|
||||
|
||||
pool.add(ramp(960, 320))
|
||||
assertEquals(capacity, pool.size)
|
||||
assertTrue("a full pool must report ready", pool.isReady)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drains a partial fill without stale slots`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(500, 320))
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals("only what was added may come back", 320, drained.size)
|
||||
assertEquals(500f, drained.first(), 0f)
|
||||
assertEquals(819f, drained.last(), 0f)
|
||||
assertAscending(drained)
|
||||
assertEquals("draining empties the pool", 0, pool.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drains exactly the most recent samples once wrapped`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
// 10 chunks of 320 = 3200 samples through a 1280-sample pool.
|
||||
repeat(10) { pool.add(ramp(it * 320, 320)) }
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f)
|
||||
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps only the tail of an oversized chunk`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(0, 5000))
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(4999f, drained.last(), 0f)
|
||||
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles single-sample chunks`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
// Far more single-sample adds than the capacity, exercising every wrap position.
|
||||
repeat(2000) { pool.add(floatArrayOf(it.toFloat())) }
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(1999f, drained.last(), 0f)
|
||||
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `is reusable after draining`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
repeat(5) { pool.add(ramp(it * 320, 320)) }
|
||||
pool.drain()
|
||||
|
||||
// A second pass must not inherit anything from the first.
|
||||
pool.add(ramp(9000, 320))
|
||||
val drained = pool.drain()
|
||||
assertEquals(320, drained.size)
|
||||
assertEquals(9000f, drained.first(), 0f)
|
||||
assertEquals(9319f, drained.last(), 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear discards pooled audio`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(0, 640))
|
||||
pool.clear()
|
||||
|
||||
assertEquals(0, pool.size)
|
||||
assertFalse(pool.isReady)
|
||||
pool.add(ramp(7000, 320))
|
||||
val drained = pool.drain()
|
||||
assertEquals("cleared samples must not reappear", 320, drained.size)
|
||||
assertEquals(7000f, drained.first(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty chunks are ignored`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(0, 320))
|
||||
pool.add(FloatArray(0))
|
||||
assertEquals("an empty chunk must not change the pool", 320, pool.size)
|
||||
assertAscending(pool.drain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunk exactly the size of the pool is kept whole`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(100, capacity))
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(100f, drained.first(), 0f)
|
||||
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pooled audio is long enough for the detector to resolve a pitch`() {
|
||||
// The pool exists to make detection possible at all; prove the pooled length
|
||||
// actually works rather than only that the plumbing moves samples around.
|
||||
val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
val pool = CwDetectionPool(capacity)
|
||||
var phase = 0
|
||||
repeat(4) {
|
||||
pool.add(FloatArray(320) { i ->
|
||||
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
|
||||
})
|
||||
phase += 320
|
||||
}
|
||||
assertTrue(pool.isReady)
|
||||
|
||||
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
|
||||
assertEquals(
|
||||
"four pooled capture chunks must be enough to detect a 1500 Hz tone",
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a non-positive capacity`() {
|
||||
for (bad in listOf(0, -1, -1280)) {
|
||||
try {
|
||||
CwDetectionPool(bad)
|
||||
throw AssertionError("capacity $bad should have been rejected")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// The decoder derives capacity from a constant; a zero would otherwise
|
||||
// fail later as a division by zero in the ring arithmetic.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse].
|
||||
*
|
||||
* This suite exists because an earlier version of the same rule lived inside the decoder,
|
||||
* where tests could only restate it. Mutation testing then showed four injected defects -
|
||||
* removing the silence guard, comparing shifts instead of tones, never setting the anchor,
|
||||
* and inverting the hysteresis comparison - all left the suite green. Every test below
|
||||
* targets one of those, so each is now a real tripwire.
|
||||
*/
|
||||
class CwShiftDeciderTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
private val hysteresisHz = CwShiftDecider.DEFAULT_HYSTERESIS_HZ
|
||||
|
||||
private fun steadyTone(hz: Double, samples: Int = 1280): FloatArray =
|
||||
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
|
||||
|
||||
private fun noise(samples: Int = 1280, seed: Int = 1, level: Double = 0.02): FloatArray {
|
||||
val random = Random(seed)
|
||||
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
|
||||
}
|
||||
|
||||
private fun analyse(audio: FloatArray) = CwToneShifter.analyse(audio, sampleRate)
|
||||
|
||||
private fun feed(decider: CwShiftDecider, audio: FloatArray) = decider.accept(analyse(audio))
|
||||
|
||||
// --- Mutant (a): the silence guard ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `silence retains an established shift`() {
|
||||
val decider = CwShiftDecider()
|
||||
val established = feed(decider, steadyTone(1400.0))
|
||||
assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome)
|
||||
assertTrue("a 1400 Hz tone must produce a shift", established.shiftHz != 0f)
|
||||
|
||||
val silent = feed(decider, noise())
|
||||
assertEquals(
|
||||
"silence must be reported as no tone, not as a zero shift",
|
||||
CwShiftDecider.Outcome.NO_TONE, silent.outcome
|
||||
)
|
||||
assertEquals(
|
||||
"silence must not change the shift",
|
||||
established.shiftHz, silent.shiftHz, 0f
|
||||
)
|
||||
assertFalse("a silent window is not a change", silent.changed)
|
||||
assertEquals(
|
||||
"the decider's state must still hold the shift",
|
||||
established.shiftHz, decider.shiftHz, 0f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run of silence does not erode the shift`() {
|
||||
val decider = CwShiftDecider()
|
||||
val established = feed(decider, steadyTone(1400.0)).shiftHz
|
||||
|
||||
repeat(8) { i ->
|
||||
val decision = feed(decider, noise(seed = i + 2))
|
||||
assertEquals(
|
||||
"silent window $i changed the shift",
|
||||
established, decision.shiftHz, 0f
|
||||
)
|
||||
}
|
||||
assertEquals(established, decider.shiftHz, 0f)
|
||||
assertNotNull("the anchor must survive silence", decider.anchorToneHz)
|
||||
}
|
||||
|
||||
// --- Mutants (b) and (c): hysteresis anchored on the tone ----------------------
|
||||
|
||||
@Test
|
||||
fun `an estimate hopping across the window edge does not re-shift`() {
|
||||
// 1200.0 Hz is inside the window (shift 0); 1212.5 Hz, one scan bin away, is
|
||||
// outside (a large shift). A shift-space comparison lapses here because one side
|
||||
// is zero, which is exactly where the jump is largest.
|
||||
val decider = CwShiftDecider()
|
||||
val first = feed(decider, steadyTone(1212.5))
|
||||
assertEquals(CwShiftDecider.Outcome.SHIFTED, first.outcome)
|
||||
|
||||
val hop = feed(decider, steadyTone(1200.0))
|
||||
assertEquals(
|
||||
"a one-bin hop back across the edge must be absorbed",
|
||||
CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome
|
||||
)
|
||||
assertEquals("the shift must not move", first.shiftHz, hop.shiftHz, 0f)
|
||||
assertFalse(hop.changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the anchor is set from the tone that produced the shift`() {
|
||||
val decider = CwShiftDecider()
|
||||
assertNull("no anchor before the first detection", decider.anchorToneHz)
|
||||
|
||||
feed(decider, steadyTone(1400.0))
|
||||
assertEquals(
|
||||
"the anchor must be the detected tone",
|
||||
1400.0, decider.anchorToneHz!!.toDouble(), 25.0
|
||||
)
|
||||
|
||||
// An in-window tone must anchor too, otherwise a tone drifting from inside the
|
||||
// window to outside would be measured against a stale reference.
|
||||
feed(decider, steadyTone(700.0))
|
||||
assertEquals(
|
||||
"an in-window tone must also become the anchor",
|
||||
700.0, decider.anchorToneHz!!.toDouble(), 25.0
|
||||
)
|
||||
assertEquals("an in-window tone needs no shift", 0f, decider.shiftHz, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hysteresis is measured against the anchor, not the previous estimate`() {
|
||||
// Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison
|
||||
// against the previous estimate would never fire. Anchored, the shift updates
|
||||
// once the accumulated move clears the margin.
|
||||
val decider = CwShiftDecider()
|
||||
feed(decider, steadyTone(1300.0))
|
||||
val anchorAtStart = decider.anchorToneHz!!
|
||||
|
||||
var tone = 1325.0
|
||||
var updates = 0
|
||||
while (tone <= 1450.0) {
|
||||
if (feed(decider, steadyTone(tone)).changed) updates++
|
||||
tone += 25.0
|
||||
}
|
||||
assertTrue(
|
||||
"accumulated drift must eventually re-shift; anchor started at $anchorAtStart " +
|
||||
"and the shift updated $updates times",
|
||||
updates >= 1
|
||||
)
|
||||
}
|
||||
|
||||
// --- Mutant (d): the comparison direction --------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a large retune is followed while small moves are absorbed`() {
|
||||
val decider = CwShiftDecider()
|
||||
val before = feed(decider, steadyTone(1400.0)).shiftHz
|
||||
|
||||
// Well inside the margin: must be absorbed.
|
||||
val small = feed(decider, steadyTone(1412.5))
|
||||
assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome)
|
||||
assertEquals(before, small.shiftHz, 0f)
|
||||
|
||||
// Well beyond it: must be followed. An inverted comparison would absorb this and
|
||||
// react to the small move instead.
|
||||
val large = feed(decider, steadyTone(1000.0))
|
||||
assertTrue(
|
||||
"a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})",
|
||||
large.changed
|
||||
)
|
||||
assertEquals(
|
||||
"a 1000 Hz tone is inside the window, so no shift is needed",
|
||||
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome
|
||||
)
|
||||
assertEquals(0f, large.shiftHz, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edge tone settles instead of thrashing`() {
|
||||
val decider = CwShiftDecider()
|
||||
var changes = 0
|
||||
// Estimates hopping around the 1200 Hz edge, the worst case for a shift-space rule.
|
||||
val hops = listOf(1200.0, 1212.5, 1200.0, 1187.5, 1212.5, 1200.0, 1225.0, 1200.0)
|
||||
repeat(4) {
|
||||
for (hz in hops) {
|
||||
if (feed(decider, steadyTone(hz)).changed) changes++
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
"an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections",
|
||||
changes <= 3
|
||||
)
|
||||
}
|
||||
|
||||
// --- Drift and state consistency ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `slow drift keeps the shifted tone inside the model window`() {
|
||||
val decider = CwShiftDecider()
|
||||
var tone = 1300.0
|
||||
var worstOffset = 0.0
|
||||
while (tone <= 1550.0) {
|
||||
val decision = feed(decider, steadyTone(tone))
|
||||
val landed = tone + decision.shiftHz
|
||||
worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ))
|
||||
assertTrue(
|
||||
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window",
|
||||
CwToneShifter.isInsideWindow(landed.toFloat())
|
||||
)
|
||||
tone += 12.5
|
||||
}
|
||||
assertTrue(
|
||||
"staleness must stay near the margin, worst offset was $worstOffset Hz",
|
||||
worstOffset <= hysteresisHz + 12.5
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears both the shift and the anchor together`() {
|
||||
val decider = CwShiftDecider()
|
||||
feed(decider, steadyTone(1400.0))
|
||||
assertTrue(decider.shiftHz != 0f)
|
||||
assertNotNull(decider.anchorToneHz)
|
||||
|
||||
decider.reset()
|
||||
assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f)
|
||||
assertNull("reset must clear the anchor", decider.anchorToneHz)
|
||||
|
||||
// After a reset the next tone must be acted on rather than absorbed.
|
||||
val decision = feed(decider, steadyTone(1400.0))
|
||||
assertEquals(CwShiftDecider.Outcome.SHIFTED, decision.outcome)
|
||||
assertTrue(decision.changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-zero shift always has an anchor`() {
|
||||
// An inconsistent pair would make hysteresis behave differently depending on how
|
||||
// the state was reached, so pin the invariant across a mixed sequence.
|
||||
val decider = CwShiftDecider()
|
||||
val sequence = listOf(
|
||||
steadyTone(1400.0), noise(), steadyTone(1412.5), steadyTone(300.0),
|
||||
noise(seed = 5), steadyTone(700.0), steadyTone(1500.0), noise(seed = 9)
|
||||
)
|
||||
for ((index, audio) in sequence.withIndex()) {
|
||||
feed(decider, audio)
|
||||
if (decider.shiftHz != 0f) {
|
||||
assertNotNull(
|
||||
"step $index left a shift of ${decider.shiftHz}Hz with no anchor",
|
||||
decider.anchorToneHz
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift always lands the tone on the target`() {
|
||||
for (hz in listOf(150.0, 250.0, 300.0, 1250.0, 1400.0, 1500.0)) {
|
||||
val decider = CwShiftDecider()
|
||||
val decision = feed(decider, steadyTone(hz))
|
||||
assertEquals(
|
||||
"a ${hz}Hz tone must be shifted to the window centre",
|
||||
CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in-window tones are never shifted`() {
|
||||
for (hz in listOf(400.0, 500.0, 800.0, 1100.0, 1200.0)) {
|
||||
val decider = CwShiftDecider()
|
||||
val decision = feed(decider, steadyTone(hz))
|
||||
assertEquals(
|
||||
"a ${hz}Hz tone is inside the window and must not be shifted",
|
||||
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome
|
||||
)
|
||||
assertEquals(0f, decision.shiftHz, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Signal-level properties of the shifter: the range the spectrogram expects, the
|
||||
* detector's threshold trade-off, and behaviour on inputs a phone mic can really produce.
|
||||
*
|
||||
* The decision rule that consumes these estimates is covered by [CwShiftDeciderTest].
|
||||
*/
|
||||
class CwToneShiftSignalTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
/** Keyed CW: gated tone with noise, 60 ms on / 30 ms off, roughly 20 WPM. */
|
||||
private fun keyedTone(hz: Double, samples: Int = 1280, seed: Int = 1, noise: Double = 0.02): FloatArray {
|
||||
val random = Random(seed)
|
||||
val period = sampleRate * 90 / 1000
|
||||
return FloatArray(samples) { i ->
|
||||
val gate = if (i % period < sampleRate * 60 / 1000) 1.0 else 0.0
|
||||
(gate * sin(2.0 * PI * hz * i / sampleRate) +
|
||||
(random.nextDouble() - 0.5) * 2 * noise).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun noiseOnly(samples: Int = 1280, seed: Int = 2, level: Double = 1.0): FloatArray {
|
||||
val random = Random(seed)
|
||||
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The prominence threshold sits between two measured populations and both sides
|
||||
* matter. Too low and noise is mistaken for a tone, which moves a good signal out of
|
||||
* the model's range; too high and copyable weak signals are never shifted, which is
|
||||
* the very failure the feature exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun `prominence threshold rejects noise without rejecting weak signals`() {
|
||||
var falsePositives = 0
|
||||
repeat(20) { seed ->
|
||||
if (CwToneShifter.detectToneHz(noiseOnly(seed = seed + 500), sampleRate) != null) {
|
||||
falsePositives++
|
||||
}
|
||||
}
|
||||
assertEquals("noise must never be reported as a tone", 0, falsePositives)
|
||||
|
||||
// Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible,
|
||||
// decodable, and the region an over-tight threshold silently discards.
|
||||
for (hz in listOf(300.0, 800.0, 1400.0)) {
|
||||
val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate)
|
||||
assertEquals(
|
||||
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise",
|
||||
hz, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " +
|
||||
"ceiling of ~3.4",
|
||||
CwToneShifter.MIN_PROMINENCE > 3.4
|
||||
)
|
||||
assertTrue(
|
||||
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " +
|
||||
"keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB",
|
||||
CwToneShifter.MIN_PROMINENCE < 5.2
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hilbert kernel's L1 gain is 2.51, so summing the in-phase and quadrature paths
|
||||
* overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The
|
||||
* spectrogram takes log1p of the magnitude, so an overshoot is not fatal, but it
|
||||
* moves the level away from what the model was trained on.
|
||||
*/
|
||||
@Test
|
||||
fun `shifted output stays within the range the spectrogram expects`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
|
||||
val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
|
||||
assertTrue(
|
||||
"a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}",
|
||||
shiftedSquare.all { abs(it) <= 1f }
|
||||
)
|
||||
|
||||
shifter.reset()
|
||||
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
|
||||
val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
|
||||
assertTrue(
|
||||
"a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}",
|
||||
shiftedSine.all { abs(it) <= 1f }
|
||||
)
|
||||
|
||||
// Limiting must not flatten the signal away: the tone still has to be there.
|
||||
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
|
||||
assertEquals(
|
||||
"limiting must preserve the shifted tone",
|
||||
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stateless shift also stays in range`() {
|
||||
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
|
||||
val shifted = CwToneShifter.shift(square, -700f, sampleRate)
|
||||
assertTrue(
|
||||
"peak was ${shifted.maxOf { abs(it) }}",
|
||||
shifted.all { abs(it) <= 1f }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detector tolerates pathological input`() {
|
||||
// A wrong shift moves a perfectly good tone out of range, so a bogus estimate is
|
||||
// worse than none: these inputs must produce the right tone or nothing at all.
|
||||
for (offset in listOf(0.5, 1.0, 5.0, 50.0)) {
|
||||
val biased = FloatArray(1280) { i ->
|
||||
(offset + sin(2.0 * PI * 800.0 * i / sampleRate)).toFloat()
|
||||
}
|
||||
val detected = CwToneShifter.detectToneHz(biased, sampleRate)
|
||||
assertEquals(
|
||||
"a DC offset of $offset must not hide the tone",
|
||||
800.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
assertNull(
|
||||
"all zeros must not report a tone",
|
||||
CwToneShifter.detectToneHz(FloatArray(1280), sampleRate)
|
||||
)
|
||||
|
||||
for (size in listOf(0, 1, 2, 63)) {
|
||||
assertNull(
|
||||
"a $size-sample buffer is too short to detect from",
|
||||
CwToneShifter.detectToneHz(FloatArray(size), sampleRate)
|
||||
)
|
||||
}
|
||||
|
||||
val withNan = FloatArray(1280) { i ->
|
||||
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
|
||||
}
|
||||
assertNull(
|
||||
"a NaN sample must yield no tone rather than a garbage shift",
|
||||
CwToneShifter.detectToneHz(withNan, sampleRate)
|
||||
)
|
||||
|
||||
// Clipping must not let a harmonic outrank the fundamental.
|
||||
for (drive in listOf(1.0, 4.0, 20.0, 200.0)) {
|
||||
val clipped = FloatArray(1280) { i ->
|
||||
(drive * sin(2.0 * PI * 500.0 * i / sampleRate)).coerceIn(-1.0, 1.0).toFloat()
|
||||
}
|
||||
val detected = CwToneShifter.detectToneHz(clipped, sampleRate)
|
||||
assertEquals(
|
||||
"at ${drive}x drive the fundamental must still win",
|
||||
500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* [CwToneShifter.Streaming] exists because the decoder shifts one ~320-sample chunk at
|
||||
* a time. Shifting each chunk in isolation makes the Hilbert FIR convolve against zeros
|
||||
* at both edges, which distorted 62 of every 320 samples and inflated envelope ripple
|
||||
* to 8.7x the whole-buffer baseline. These tests fail if that state handling regresses.
|
||||
*/
|
||||
class CwToneShifterStreamingTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
/** ~100 ms of audio once resampled to 3200 Hz, matching what the decoder receives. */
|
||||
private val chunkSize = 320
|
||||
|
||||
private fun continuousTone(hz: Double, samples: Int): FloatArray =
|
||||
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
|
||||
|
||||
/** RMS envelope; a steady tone must produce a flat one. */
|
||||
private fun envelope(audio: FloatArray, window: Int = 48): List<Double> {
|
||||
val out = mutableListOf<Double>()
|
||||
var i = 0
|
||||
while (i + window <= audio.size) {
|
||||
var sum = 0.0
|
||||
for (j in i until i + window) sum += audio[j].toDouble() * audio[j]
|
||||
out += sqrt(sum / window)
|
||||
i += window / 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Coefficient of variation of the envelope, as a percentage. */
|
||||
private fun ripple(audio: FloatArray, skip: Int = 0): Double {
|
||||
val env = envelope(audio.copyOfRange(skip, audio.size))
|
||||
val mean = env.average()
|
||||
if (mean == 0.0) return 0.0
|
||||
val variance = env.sumOf { (it - mean) * (it - mean) } / env.size
|
||||
return sqrt(variance) / mean * 100.0
|
||||
}
|
||||
|
||||
private fun processInChunks(audio: FloatArray, shiftHz: Float): FloatArray {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val out = FloatArray(audio.size)
|
||||
var offset = 0
|
||||
while (offset < audio.size) {
|
||||
val end = minOf(offset + chunkSize, audio.size)
|
||||
val chunk = audio.copyOfRange(offset, end)
|
||||
shifter.process(chunk, shiftHz, sampleRate).copyInto(out, offset)
|
||||
offset = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunked streaming keeps a steady tone flat`() {
|
||||
val audio = continuousTone(1500.0, chunkSize * 20)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
val streamed = processInChunks(audio, shiftHz)
|
||||
|
||||
// Skip the filter's start-up transient: with no history the first taps are cold.
|
||||
val skip = 128
|
||||
val streamedRipple = ripple(streamed, skip)
|
||||
|
||||
// Absolute, not relative to the whole-buffer figure: clamping pins a full-scale
|
||||
// tone at exactly 1.0, so the whole-buffer ripple collapses to ~0.001% and any
|
||||
// ratio against it explodes. What matters is the absolute number - a 20 WPM dot
|
||||
// spans 192 samples, so sub-2% envelope ripple cannot move a keying decision.
|
||||
// Measured 0.79% with state carried across chunks; dropping the filter history
|
||||
// takes it to several percent, and dropping the phase far higher.
|
||||
assertTrue(
|
||||
"streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " +
|
||||
"filter state or mixer phase is not being carried",
|
||||
streamedRipple < 2.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming must match whole-buffer shifting everywhere except the last
|
||||
* [lookahead] samples of each chunk.
|
||||
*
|
||||
* That exception is causal, not a defect: producing output sample `i` needs input
|
||||
* up to `i + HILBERT_DELAY`, which for the tail of a chunk has not been captured
|
||||
* yet. A whole-buffer call sees those samples; a live stream cannot. Measured, the
|
||||
* divergence is confined to the final 3 samples of each 320-sample chunk (under 1%
|
||||
* of the audio) and vanishes immediately after the boundary, which is why the
|
||||
* decoder accepts it rather than delaying output by 10 ms.
|
||||
*/
|
||||
@Test
|
||||
fun `chunked output matches whole-buffer output except the causal tail`() {
|
||||
val audio = continuousTone(1500.0, chunkSize * 12)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
val whole = CwToneShifter.shift(audio, shiftHz, sampleRate)
|
||||
val streamed = processInChunks(audio, shiftHz)
|
||||
|
||||
val lookahead = 32 // HILBERT_TAPS / 2, rounded up
|
||||
val skip = 128 // filter start-up transient
|
||||
var worstInterior = 0.0
|
||||
var worstTail = 0.0
|
||||
for (i in skip until audio.size) {
|
||||
val distanceToBoundary = chunkSize - (i % chunkSize)
|
||||
val delta = abs(whole[i] - streamed[i]).toDouble()
|
||||
if (distanceToBoundary <= lookahead) {
|
||||
worstTail = maxOf(worstTail, delta)
|
||||
} else {
|
||||
worstInterior = maxOf(worstInterior, delta)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"away from chunk tails the two must agree; worst divergence was " +
|
||||
"$worstInterior, so filter history or mixer phase is not being carried",
|
||||
worstInterior < 0.01
|
||||
)
|
||||
// The tail is allowed to differ, but not wildly: a broken implementation would
|
||||
// diverge by the full signal amplitude rather than a fraction of it.
|
||||
assertTrue(
|
||||
"chunk-tail divergence $worstTail exceeds the causal lookahead budget",
|
||||
worstTail < 0.5
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifted chunks land on the target frequency`() {
|
||||
val audio = continuousTone(1500.0, chunkSize * 16)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
val streamed = processInChunks(audio, shiftHz)
|
||||
|
||||
val detected = CwToneShifter.detectToneHz(streamed, sampleRate)
|
||||
assertEquals(
|
||||
"streamed audio must end up at the target pitch",
|
||||
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero shift passes chunks through untouched`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val chunk = continuousTone(800.0, chunkSize)
|
||||
assertSame(
|
||||
"a zero shift must not copy or alter the chunk",
|
||||
chunk, shifter.process(chunk, 0f, sampleRate)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history survives a run of zero-shift chunks`() {
|
||||
// Feeding audio while disabled must still fill the history, so that enabling
|
||||
// the shift mid-stream does not convolve against leftover silence.
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val audio = continuousTone(1500.0, chunkSize * 6)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
// First three chunks with no shift, then start shifting.
|
||||
var offset = 0
|
||||
repeat(3) {
|
||||
shifter.process(audio.copyOfRange(offset, offset + chunkSize), 0f, sampleRate)
|
||||
offset += chunkSize
|
||||
}
|
||||
val firstShifted = shifter.process(
|
||||
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
|
||||
)
|
||||
|
||||
// With history primed the very first shifted chunk should already be clean;
|
||||
// a cold filter would show a large amplitude dip at its start.
|
||||
val head = envelope(firstShifted.copyOfRange(0, 96)).average()
|
||||
val tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average()
|
||||
assertTrue(
|
||||
"first shifted chunk starts at $head but settles at $tail; history was not kept",
|
||||
head > tail * 0.7
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears state so the next chunk starts cold`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val audio = continuousTone(1500.0, chunkSize * 4)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
var offset = 0
|
||||
repeat(3) {
|
||||
shifter.process(audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate)
|
||||
offset += chunkSize
|
||||
}
|
||||
shifter.reset()
|
||||
|
||||
val afterReset = shifter.process(
|
||||
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
|
||||
)
|
||||
// Cold filter: the leading samples are attenuated relative to the settled tail.
|
||||
val head = envelope(afterReset.copyOfRange(0, 64)).average()
|
||||
val tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average()
|
||||
assertTrue(
|
||||
"reset must clear history, so the head ($head) should be quieter than " +
|
||||
"the settled tail ($tail)",
|
||||
head < tail
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles chunks larger than the history window`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val big = continuousTone(1500.0, 5000)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
val out = shifter.process(big, shiftHz, sampleRate)
|
||||
assertEquals(big.size, out.size)
|
||||
assertTrue("output must be finite", out.all { it.isFinite() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles chunks smaller than the history window`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
// 16-sample chunks are far below the 62-sample history; the ring must still work.
|
||||
val audio = continuousTone(1500.0, 16 * 40)
|
||||
var offset = 0
|
||||
val collected = FloatArray(audio.size)
|
||||
while (offset < audio.size) {
|
||||
val chunk = audio.copyOfRange(offset, offset + 16)
|
||||
shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset)
|
||||
offset += 16
|
||||
}
|
||||
assertTrue("output must be finite", collected.all { it.isFinite() })
|
||||
val detected = CwToneShifter.detectToneHz(collected, sampleRate)
|
||||
assertEquals(
|
||||
"even tiny chunks must end up at the target pitch",
|
||||
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 40.0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The shifter exists so pitches outside the model's 400-1200 Hz window can still be
|
||||
* decoded. These tests pin the two properties that make it safe to enable:
|
||||
* in-window audio is returned untouched, and shifted audio contains one clean tone.
|
||||
*/
|
||||
class CwToneShifterTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
/** Keyed CW-like tone: a gated sine with smooth edges, plus noise. */
|
||||
private fun cwTone(hz: Double, samples: Int = 1600, noise: Double = 0.02): FloatArray {
|
||||
val random = Random(42)
|
||||
return FloatArray(samples) { i ->
|
||||
// Gate on for 60 ms, off for 30 ms, repeating - roughly 20 WPM keying.
|
||||
val cyclePos = (i % (sampleRate * 90 / 1000))
|
||||
val gate = if (cyclePos < sampleRate * 60 / 1000) 1.0 else 0.0
|
||||
val value = gate * sin(2.0 * PI * hz * i / sampleRate)
|
||||
(value + (random.nextDouble() - 0.5) * 2 * noise).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Relative magnitude at [hz] using a single-bin DFT with a Hann window. */
|
||||
private fun magnitudeAt(audio: FloatArray, hz: Double): Double {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val window = 0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))
|
||||
val value = audio[i] * window
|
||||
real += value * cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
return hypot(real, imag) / audio.size
|
||||
}
|
||||
|
||||
/** Scan 100 Hz..Nyquist and return the strongest bin plus everything above a ratio. */
|
||||
private fun peaks(audio: FloatArray, minRatio: Double = 0.3): Pair<Double, List<Double>> {
|
||||
val magnitudes = mutableListOf<Pair<Double, Double>>()
|
||||
var hz = 100.0
|
||||
while (hz <= sampleRate / 2.0) {
|
||||
magnitudes += hz to magnitudeAt(audio, hz)
|
||||
hz += 12.5
|
||||
}
|
||||
val strongest = magnitudes.maxByOrNull { it.second }!!
|
||||
val others = magnitudes
|
||||
.filter { it.first != strongest.first && it.second >= strongest.second * minRatio }
|
||||
// Collapse adjacent bins of the same lobe; only distinct tones matter.
|
||||
.filter { abs(it.first - strongest.first) > 50.0 }
|
||||
.map { it.first }
|
||||
return strongest.first to others
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detects tones across the audible range`() {
|
||||
for (tone in listOf(150.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1300.0, 1500.0)) {
|
||||
val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate)
|
||||
assertNotNull("no tone detected at $tone Hz", detected)
|
||||
assertEquals("detected pitch off at $tone Hz", tone, detected!!.toDouble(), 25.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports no tone for noise`() {
|
||||
val random = Random(7)
|
||||
val noise = FloatArray(1600) { ((random.nextDouble() - 0.5) * 2).toFloat() }
|
||||
assertNull("noise must not be mistaken for a tone", CwToneShifter.detectToneHz(noise, sampleRate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in-window tones are returned untouched`() {
|
||||
for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) {
|
||||
val audio = cwTone(tone)
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertFalse("$tone Hz is inside the window, must not shift", analysis.needsShift)
|
||||
assertEquals("no shift expected at $tone Hz", 0f, analysis.shiftHz, 0f)
|
||||
// Same instance: the caller's array must not even be copied.
|
||||
assertSame("in-window audio must be passed through", audio, result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoder runs this scan even with shifting switched off, purely to tell the
|
||||
* operator why nothing is decoding. That only works if the scan reaches past the
|
||||
* model's window: the spectrogram's own pitch readout cannot, being confined to the
|
||||
* window by construction, and it reports edge leakage as though it were the tone.
|
||||
*/
|
||||
@Test
|
||||
fun `the scan reports tones the model window excludes`() {
|
||||
for (tone in listOf(120.0, 250.0, 1400.0, 1500.0)) {
|
||||
val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate)
|
||||
val reported = analysis.toneHz
|
||||
assertNotNull("$tone Hz went undetected, so the UI has nothing to report", reported)
|
||||
assertEquals("$tone Hz was misreported", tone, reported!!.toDouble(), 30.0)
|
||||
assertFalse(
|
||||
"$tone Hz must read as outside the window",
|
||||
CwToneShifter.isInsideWindow(reported)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The waterfall draws a marker at [CwToneShifter.TARGET_HZ] to show the operator where
|
||||
* a shifted tone is being delivered. Moving the target outside the model's window, or
|
||||
* moving the window off the target, would leave that marker pointing at a frequency
|
||||
* nothing arrives at — and nothing else in the build would object.
|
||||
*/
|
||||
@Test
|
||||
fun `the shift target sits inside the model window, clear of its edges`() {
|
||||
assertTrue(
|
||||
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is outside the model window " +
|
||||
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz",
|
||||
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
|
||||
)
|
||||
// Clear of the edges by a decent margin, so a tone landing a little off target
|
||||
// still lands inside: a target hugging an edge would make the shift pointless.
|
||||
val margin = (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 4
|
||||
assertTrue(
|
||||
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is within $margin Hz of a window edge",
|
||||
CwToneShifter.TARGET_HZ >= CwDeepSpectrogram.MIN_FREQ_HZ + margin &&
|
||||
CwToneShifter.TARGET_HZ <= CwDeepSpectrogram.MAX_FREQ_HZ - margin
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-window tones move to the target with no competing tone`() {
|
||||
for (tone in listOf(150.0, 200.0, 250.0, 300.0, 350.0, 1300.0, 1400.0, 1500.0)) {
|
||||
val audio = cwTone(tone)
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertTrue("$tone Hz is outside the window, must shift", analysis.needsShift)
|
||||
|
||||
val (strongest, competing) = peaks(result)
|
||||
assertEquals(
|
||||
"$tone Hz did not land on the target",
|
||||
CwToneShifter.TARGET_HZ, strongest, 30.0
|
||||
)
|
||||
assertTrue(
|
||||
"$tone Hz left a competing tone at $competing (single-sideband mixing failed)",
|
||||
competing.isEmpty()
|
||||
)
|
||||
assertTrue(
|
||||
"shifted tone must land inside the model window",
|
||||
CwToneShifter.isInsideWindow(strongest.toFloat())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift with zero offset returns the same array`() {
|
||||
val audio = cwTone(800.0)
|
||||
assertSame(audio, CwToneShifter.shift(audio, 0f, sampleRate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift preserves length and stays finite`() {
|
||||
val audio = cwTone(1500.0)
|
||||
val shifted = CwToneShifter.shift(audio, -700f, sampleRate)
|
||||
assertEquals("length must be preserved", audio.size, shifted.size)
|
||||
assertTrue("output must be finite", shifted.all { it.isFinite() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input is handled`() {
|
||||
val empty = FloatArray(0)
|
||||
assertSame(empty, CwToneShifter.shift(empty, -700f, sampleRate))
|
||||
assertNull(CwToneShifter.detectToneHz(empty, sampleRate))
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(empty, sampleRate)
|
||||
assertSame(empty, result)
|
||||
assertFalse(analysis.needsShift)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifted audio survives the spectrogram with energy inside the window`() {
|
||||
// End-to-end: a 1500 Hz tone is invisible to the model, the shifted one is not.
|
||||
val audio = cwTone(1500.0, samples = 3200)
|
||||
|
||||
val rawSpectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val rawEnergy = rawSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
|
||||
|
||||
val (shifted, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertTrue(analysis.needsShift)
|
||||
val shiftedSpectrogram = CwDeepSpectrogram.compute(shifted)
|
||||
val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
|
||||
|
||||
assertTrue(
|
||||
"shifting must put more energy in the model window (raw=$rawEnergy shifted=$shiftedEnergy)",
|
||||
shiftedEnergy > rawEnergy * 1.5
|
||||
)
|
||||
assertEquals(
|
||||
"bin count must stay compatible with the model",
|
||||
CwDeepSpectrogram.FREQUENCY_BINS, shiftedSpectrogram[0].size
|
||||
)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user