Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc6db5aff | ||
|
|
92499b1cf1 | ||
|
|
828fd0fb6f | ||
|
|
07df22d287 | ||
|
|
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 | ||
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
8b5960282f | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 | ||
|
|
4b835bac5c | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -70,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,27 +1,3 @@
|
||||
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.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -44,17 +44,17 @@ neural decoding model, licensed under the GNU Affero General Public License v3.0
|
||||
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, attribution and the applied int8 quantization are documented in
|
||||
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>
|
||||
@@ -64,6 +64,16 @@ class AprsForegroundService : Service() {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,20 +20,18 @@ package com.rtbishop.look4sat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
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.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -51,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
|
||||
@@ -67,6 +65,7 @@ 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
|
||||
@@ -83,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
|
||||
@@ -97,41 +97,45 @@ 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.status.SatStatusScreen
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,7 @@ 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() }
|
||||
@@ -148,27 +152,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
|
||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
if (idx != -1) idx
|
||||
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
|
||||
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
|
||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||
val mainNavItems = remember(allNavItems, subOrder) {
|
||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||
// 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 moreNavItems = remember(allNavItems, subOrder) {
|
||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
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
|
||||
@@ -188,20 +192,16 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
mainNavItems.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.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.AmSat -> screen is Screen.AmSat
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// 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 = {
|
||||
@@ -260,7 +260,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
@@ -287,7 +286,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.AmSat> {
|
||||
SatStatusScreen(container = container)
|
||||
SatStatusDestination()
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
@@ -346,14 +345,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
|
||||
// More-menu popup panel (slim strip anchored to the bottom-right corner)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = expandVertically(
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
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,
|
||||
@@ -369,4 +372,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||
*
|
||||
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
|
||||
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
|
||||
* (MainScreen's AnimatedVisibility + spring).
|
||||
* 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.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -45,31 +46,30 @@ fun MoreMenuPopup(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||
// 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),
|
||||
.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.surfaceContainerHigh
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.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.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -35,27 +35,36 @@ class AprsIsClient(
|
||||
fun connect() {
|
||||
disconnect()
|
||||
val s = Socket()
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,24 +77,31 @@ class AprsIsClient(
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
}
|
||||
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
|
||||
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())
|
||||
// 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
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one line (server response; throws on timeout) */
|
||||
|
||||
@@ -25,6 +25,9 @@ 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
|
||||
@@ -48,9 +51,18 @@ import java.nio.FloatBuffer
|
||||
* 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.
|
||||
* 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) : ICwDecoder {
|
||||
class CwDeepDecoder(
|
||||
context: Context,
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
@@ -60,17 +72,64 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
/** 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()
|
||||
|
||||
/**
|
||||
* Archived text, plus a provisional decode of audio not yet archived.
|
||||
*
|
||||
* Kept as one flow of the two parts concatenated. Without the provisional part the
|
||||
* transcript visibly shrank: audio leaving the 20 s window waits for a full
|
||||
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
|
||||
* characters were in neither place - measured, up to 30 characters at 20 WPM would
|
||||
* vanish and reappear later, which reads as the box deleting text.
|
||||
*/
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
|
||||
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||
private var committedText = ""
|
||||
|
||||
/** Provisional decode of the pending archive batch, replaced when it is archived. */
|
||||
private var pendingText = ""
|
||||
|
||||
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()
|
||||
|
||||
@@ -95,6 +154,31 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
/** 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()
|
||||
@@ -174,15 +258,30 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val shouldRedecode = buffer.append(resampled)
|
||||
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) {
|
||||
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
|
||||
// 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
|
||||
@@ -223,6 +322,144 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// The provisional text describes audio being discarded, so it goes with it.
|
||||
// Committed text stays: it was correct for audio that really was archived.
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -249,9 +486,27 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
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
|
||||
}
|
||||
// This batch is final, so its provisional decode is superseded rather than kept.
|
||||
committedText += text
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the audio waiting to be archived, so it stays on screen until it is.
|
||||
*
|
||||
* Provisional: the batch is still growing, and the final decode sees all of it at once
|
||||
* with more context. Runs on the redecode cycle rather than per capture chunk -
|
||||
* decoding every 100 ms chunk separately measured 14x the inference load, over 250% of
|
||||
* one core, and a chunk that short carries under two dot-lengths of context anyway.
|
||||
*/
|
||||
private suspend fun decodePending(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)
|
||||
pendingText = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
_historyText.value = committedText + pendingText
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
@@ -308,20 +563,47 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
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
|
||||
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
|
||||
// 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
|
||||
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||
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 = ""
|
||||
committedText = ""
|
||||
pendingText = ""
|
||||
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() {
|
||||
|
||||
@@ -28,6 +28,12 @@ import android.util.Log
|
||||
*/
|
||||
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) {
|
||||
@@ -39,6 +45,7 @@ internal object CwProbe {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -267,12 +267,18 @@ class RadioTrackingService(
|
||||
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||
txNow.setFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
// 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)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
if (rxNow.setFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +456,15 @@ 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)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
if (radio.setWorkingFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (txRadioFreq != null) {
|
||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||
radio.setTxVfoFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
if (radio.setTxVfoFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@ 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()
|
||||
@@ -104,7 +111,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
|
||||
// 面板与独立 CW 页各自持有自己的解码器
|
||||
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
|
||||
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
|
||||
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)
|
||||
|
||||
|
||||
+127
-30
@@ -15,8 +15,13 @@ import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** One report from the AMSAT API (data layer model). */
|
||||
private data class ApiReport(
|
||||
/**
|
||||
* 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,
|
||||
@@ -35,16 +40,31 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: 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) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
|
||||
// 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 */
|
||||
@@ -52,7 +72,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -73,47 +93,114 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} 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 (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
/**
|
||||
* 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"))
|
||||
val labels = (0 until 6).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
|
||||
// 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 slots = (0 until 72).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val days = (0 until 6).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
SatDay(dateLabel = labels[dayIdx], slots = slots)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
@@ -152,5 +239,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
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
|
||||
}
|
||||
}
|
||||
+71
-49
@@ -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,19 +71,23 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
@@ -117,45 +129,55 @@ class SatelliteRepo(
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
) {
|
||||
_isCalculating.value = true
|
||||
// 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
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
// 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
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
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
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
}
|
||||
|
||||
private fun isAosInRange(
|
||||
@@ -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) {
|
||||
|
||||
+69
-22
@@ -29,15 +29,18 @@ 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.source.Sources
|
||||
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,
|
||||
@@ -69,9 +72,9 @@ 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"
|
||||
@@ -100,13 +103,18 @@ class SettingsRepo(
|
||||
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)
|
||||
@@ -114,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> {
|
||||
@@ -126,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
|
||||
|
||||
@@ -144,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
|
||||
}
|
||||
|
||||
@@ -155,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
|
||||
@@ -181,7 +185,10 @@ 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
|
||||
@@ -262,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 ->
|
||||
@@ -325,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)
|
||||
@@ -334,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)
|
||||
@@ -342,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(
|
||||
@@ -354,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",
|
||||
@@ -390,6 +405,11 @@ class SettingsRepo(
|
||||
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
|
||||
}
|
||||
@@ -413,7 +433,11 @@ class SettingsRepo(
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||
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
|
||||
|
||||
@@ -445,6 +469,7 @@ class SettingsRepo(
|
||||
transceiversUrl = txUrl
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -484,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) }
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
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 java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* AMSAT satellite status page parser (https://amsat.org/status/)
|
||||
*
|
||||
* Page structure (static HTML, verified 2026-08):
|
||||
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
|
||||
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
|
||||
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
|
||||
* docTips.show('id') link
|
||||
* - Report details live in the page's inline JS:
|
||||
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
|
||||
*
|
||||
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
|
||||
*/
|
||||
object AmSatParser {
|
||||
|
||||
private val STATUS_COLORS = mapOf(
|
||||
"#648fff" to 0xFF648FFF.toLong(),
|
||||
"#ffb000" to 0xFFFFB000.toLong(),
|
||||
"#dc267f" to 0xFFDC267F.toLong(),
|
||||
"#fe6100" to 0xFFFE6100.toLong()
|
||||
)
|
||||
private val GRAY = 0xFFC0C0C0.toLong()
|
||||
|
||||
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||
private val tipRe = Pattern.compile(
|
||||
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||
)
|
||||
|
||||
/** Parse the full page */
|
||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||
val reports = parseReports(html)
|
||||
val statuses = parseStatusTable(html, reports)
|
||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||
}
|
||||
|
||||
/** Extract all reports (tips.* JS arrays) */
|
||||
fun parseReports(html: String): Map<String, SatReport> {
|
||||
val map = mutableMapOf<String, SatReport>()
|
||||
val m = tipRe.matcher(html)
|
||||
while (m.find()) {
|
||||
val id = m.group(1)
|
||||
val parts = m.group(2).split("<br>")
|
||||
map[id] = SatReport(
|
||||
id = id,
|
||||
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||
call = parts.getOrElse(1) { "" }.trim(),
|
||||
grid = parts.getOrElse(2) { "" }.trim(),
|
||||
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||
)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Parse the status table (48 satellite rows) */
|
||||
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||
val result = mutableListOf<SatStatus>()
|
||||
val tables = extractTables(html)
|
||||
for (table in tables) {
|
||||
val rows = rowRe.matcher(table)
|
||||
val parsed = mutableListOf<SatStatus>()
|
||||
var rowIndex = 0
|
||||
var dayHeaders: List<String> = emptyList()
|
||||
while (rows.find()) {
|
||||
val rowHtml = rows.group(1)
|
||||
val cells = cellRe.matcher(rowHtml)
|
||||
val cellList = mutableListOf<String>()
|
||||
while (cells.find()) cellList.add(cells.group(1))
|
||||
if (rowIndex == 0) {
|
||||
// Header: Name + 6 days (each colspan=12)
|
||||
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||
rowIndex++
|
||||
continue
|
||||
}
|
||||
if (cellList.size < 7) { rowIndex++; continue }
|
||||
val name = stripHtml(cellList[0])
|
||||
if (name.isBlank()) { rowIndex++; continue }
|
||||
val days = mutableListOf<SatDay>()
|
||||
for (d in 0 until 6) {
|
||||
val start = 1 + d * 12
|
||||
val end = start + 12
|
||||
val slots = (start until end).mapNotNull { i ->
|
||||
cellList.getOrNull(i)?.let { cell ->
|
||||
val bg = bgRe.matcher(cell)
|
||||
val color = if (bg.find()) {
|
||||
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||
} else GRAY
|
||||
val link = linkRe.matcher(cell)
|
||||
val ids = mutableListOf<String>()
|
||||
while (link.find()) ids.add(link.group(1))
|
||||
val hasReport = ids.isNotEmpty()
|
||||
val count = if (hasReport) {
|
||||
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||
} else 0
|
||||
SatSlot(
|
||||
statusColor = if (hasReport) color else GRAY,
|
||||
count = count,
|
||||
reportIds = ids
|
||||
)
|
||||
}
|
||||
}
|
||||
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||
}
|
||||
parsed.add(SatStatus(name, days))
|
||||
rowIndex++
|
||||
}
|
||||
if (parsed.isNotEmpty()) {
|
||||
result.addAll(parsed)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun extractTables(html: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||
while (m.find()) result.add(m.group())
|
||||
return result
|
||||
}
|
||||
|
||||
private fun stripHtml(s: String): String =
|
||||
s.replace(Regex("<[^>]+>"), "").trim()
|
||||
}
|
||||
@@ -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,35 +37,27 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://amsat.org/status/")
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat status exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -76,14 +68,17 @@ class RemoteSource(
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat/4.5.5")
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -92,14 +87,35 @@ class RemoteSource(
|
||||
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.5")
|
||||
.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)
|
||||
}
|
||||
@@ -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 } }
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -125,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 {
|
||||
@@ -166,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))
|
||||
@@ -177,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(
|
||||
@@ -194,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
|
||||
|
||||
@@ -223,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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).
|
||||
@@ -35,20 +36,31 @@ object AprsPacket {
|
||||
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/** Altitude extension /A=00000 (feet) */
|
||||
/**
|
||||
* 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 ""
|
||||
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return String.format(Locale.ROOT, "/A=%06d", feet)
|
||||
}
|
||||
|
||||
/** Speed/course extension (knots/degrees) */
|
||||
/**
|
||||
* 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()
|
||||
return String.format("/%03d/%03d", bearing.toInt(), knots)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,17 +112,17 @@ class AprsPosition(
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format("%d . ", minutes / 10)
|
||||
3 -> String.format("%02d. ", minutes)
|
||||
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format("%02d.%02d", minutes, hundredths)
|
||||
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("%02d%s%c", degrees, frac, ns)
|
||||
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format("%03d%s%c", degrees, frac, ew)
|
||||
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-8
@@ -49,12 +49,33 @@ object CwDeepSpectrogram {
|
||||
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
|
||||
const val HOP_LENGTH = 48
|
||||
|
||||
private const val MIN_FREQ_HZ = 400.0
|
||||
private const val MAX_FREQ_HZ = 1200.0
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -104,17 +125,30 @@ object CwDeepSpectrogram {
|
||||
*
|
||||
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
|
||||
*/
|
||||
fun compute(audio: FloatArray): Array<FloatArray> {
|
||||
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, MIN_FREQ_HZ, MAX_FREQ_HZ
|
||||
)
|
||||
val (startBin, stopBin) = frequencyBinRange(SAMPLE_RATE, FFT_LENGTH, minHz, maxHz)
|
||||
val bins = stopBin - startBin
|
||||
require(bins == FREQUENCY_BINS) {
|
||||
"expected $FREQUENCY_BINS bins, computed $bins"
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -43,9 +43,28 @@ interface ICwDecoder {
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
/** Detected tone frequency in Hz, or null before a tone is found. */
|
||||
/**
|
||||
* 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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,10 +23,11 @@ data class SatDay(
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 6 days of state */
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 6 天(新→旧)
|
||||
val days: List<SatDay>, // 3 天(新→旧)
|
||||
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -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,
|
||||
@@ -73,7 +73,25 @@ data class OtherSettings(
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false
|
||||
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) {
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ interface IMainContainer {
|
||||
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
|
||||
|
||||
+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)
|
||||
|
||||
+7
-2
@@ -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
|
||||
@@ -78,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
|
||||
}
|
||||
@@ -23,12 +23,14 @@ interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
|
||||
/** Fetch AMSAT status page HTML (with UA; null = failure) */
|
||||
suspend fun getStatusHtml(): String?
|
||||
|
||||
/** 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?
|
||||
}
|
||||
@@ -52,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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+21
-2
@@ -114,11 +114,30 @@ object DopplerFrequencyCalculator {
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear")
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+12
-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,11 +100,13 @@ 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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name.
|
||||
*
|
||||
* Why the catalogue number and not the name: the same satellite carries different names in
|
||||
* different TLE sources, so a name-keyed table misses whenever the user switches source.
|
||||
* Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in
|
||||
* both are named differently - NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the
|
||||
* other, 43700 is "ES'HAIL 2" against "QO-100". The catalogue number is identical in every
|
||||
* source, so it is the only stable key.
|
||||
*
|
||||
* LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list
|
||||
* (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7
|
||||
* instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling
|
||||
* held in LotwSatellites rather than to whatever the TLE happens to say.
|
||||
*
|
||||
* Every number here was read out of live TLE data, never typed from memory. Entries cover the
|
||||
* satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in
|
||||
* the LoTW list; the rest of that list is satellites no source still carries, so no user can
|
||||
* track them and no mapping is needed for them.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
object LotwSatelliteIds {
|
||||
|
||||
/**
|
||||
* NORAD catalogue number to the LoTW spelling. The trailing comment is one name the
|
||||
* satellite goes by in the sources, kept so a reader can recognise the entry.
|
||||
*
|
||||
* Three numbers had to be decided rather than derived, because one name matched several
|
||||
* catalogued objects. Each was settled by which object the amateur-specific sources carry:
|
||||
* - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY),
|
||||
* (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work.
|
||||
* - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls
|
||||
* it ROBUSTA 1F, which is a different satellite.
|
||||
* - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears
|
||||
* only in the general catalogue.
|
||||
*/
|
||||
private val idToName: Map<Int, String> = mapOf(
|
||||
7530 to "AO-7", // AO-07
|
||||
14129 to "AO-10", // PHASE 3B (AO-10)
|
||||
20439 to "AO-16", // OSCAR 16 (PACSAT)
|
||||
20442 to "LO-19", // LO-19
|
||||
22825 to "AO-27", // AO-27
|
||||
23439 to "RS-15", // RADIO ROSTO (RS-15)
|
||||
24278 to "FO-29", // FO-29
|
||||
25544 to "ARISS", // ISS (ZARYA)
|
||||
26609 to "AO-40", // PHASE 3D (AO-40)
|
||||
26931 to "NO-44", // NO-44
|
||||
27607 to "SO-50", // SAUDISAT 1C (SO-50)
|
||||
28650 to "VO-52", // HAMSAT (VO-52)
|
||||
39444 to "AO-73", // AO-73
|
||||
40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1
|
||||
40074 to "UKUBE1", // UKUBE-1
|
||||
40908 to "CAS-3H", // LILACSAT-2
|
||||
40931 to "IO-86", // IO-86
|
||||
40967 to "AO-85", // FOX-1A (AO-85)
|
||||
41847 to "CAS-2T", // CAS-2T
|
||||
43017 to "AO-91", // AO-91
|
||||
43678 to "PO-101", // DIWATA-2B
|
||||
43700 to "QO-100", // ES'HAIL 2
|
||||
43803 to "JO-97", // JO-97
|
||||
44530 to "TAURUS", // TAURUS-1
|
||||
44881 to "TO-108", // CAS-6 (TO-108)
|
||||
44909 to "RS-44", // DOSAAF-85 (RS-44)
|
||||
50466 to "HO-113", // CAMSAT XW-3 (CAS-9)
|
||||
53109 to "IO-117", // GREENCUBE (IO-117)
|
||||
61781 to "AO-123" // AO-123
|
||||
)
|
||||
|
||||
/** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */
|
||||
fun nameFor(catnum: Int): String? = idToName[catnum]
|
||||
|
||||
/** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */
|
||||
fun isKnown(catnum: Int): Boolean = catnum in idToName
|
||||
|
||||
/** Entry count, so a test can catch the table being emptied by a bad edit. */
|
||||
val size: Int get() = idToName.size
|
||||
}
|
||||
@@ -106,6 +106,51 @@ object WaveLogApi {
|
||||
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()
|
||||
@@ -142,10 +187,11 @@ object WaveLogApi {
|
||||
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", "SAT")
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
@@ -155,6 +201,7 @@ object WaveLogApi {
|
||||
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)")
|
||||
@@ -178,14 +225,15 @@ object WaveLogApi {
|
||||
}
|
||||
|
||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
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", "SAT"))
|
||||
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) {
|
||||
@@ -195,9 +243,14 @@ object WaveLogApi {
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
append(field("rst_sent", "59"))
|
||||
append(field("rst_rcvd", "59"))
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||
// 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>")
|
||||
|
||||
@@ -81,6 +81,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
-12
@@ -3,12 +3,17 @@ 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,
|
||||
@@ -18,7 +23,7 @@ class DopplerFrequencyCalculatorTest {
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = info, isAlive = true,
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
@@ -68,19 +73,99 @@ class DopplerFrequencyCalculatorTest {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -95,24 +180,23 @@ 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)
|
||||
@@ -171,13 +255,11 @@ class DopplerFrequencyCalculatorTest {
|
||||
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
|
||||
@@ -185,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)
|
||||
}
|
||||
|
||||
@@ -70,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(
|
||||
@@ -85,6 +87,55 @@ class QthConverterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
+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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -61,6 +62,46 @@ class CwDeepSpectrogramTest {
|
||||
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() }
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Menu layout rules. Every case here is a bug that shipped at least once, so
|
||||
* treat a failure as a regression rather than a spec question.
|
||||
*/
|
||||
class MenuLayoutTest {
|
||||
|
||||
private val all = listOf(
|
||||
"Satellites", "Passes", "Radar", "Mutual", "Roaming",
|
||||
"CwDecode", "WavelogLog", "AMSAT", "Map", "Settings"
|
||||
)
|
||||
|
||||
private fun layout(
|
||||
screenOrder: List<String> = emptyList(),
|
||||
subMenuOrder: List<String> = emptyList(),
|
||||
hidden: List<String> = emptyList()
|
||||
) = MenuLayout.resolve(all, screenOrder, subMenuOrder, hidden)
|
||||
|
||||
@Test
|
||||
fun defaultsPutFivePagesOnTheBarAndTheRestBehindMore() {
|
||||
val l = layout()
|
||||
assertEquals(listOf("Satellites", "Passes", "Radar", "Map", "Settings"), l.mainIds)
|
||||
assertEquals(listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT"), l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsIsAlwaysReachable() {
|
||||
// Moving any page onto the bar used to push Settings out of BOTH menus,
|
||||
// permanently locking the user out of the settings page.
|
||||
for (page in listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")) {
|
||||
val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue(
|
||||
"moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}",
|
||||
"Settings" in l.mainIds || "Settings" in l.moreIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAmsatToMainActuallyTakesEffect() {
|
||||
// The legacy migration re-appended AMSAT to the sub-menu unconditionally,
|
||||
// silently undoing the user's choice.
|
||||
val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds)
|
||||
assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingWavelogLogToMainActuallyTakesEffect() {
|
||||
val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("WavelogLog missing from the bar: ${l.mainIds}", "WavelogLog" in l.mainIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newPagesUnknownToPersistedOrderDefaultToTheMoreMenu() {
|
||||
// Upgrading from a build that predates AMSAT and WavelogLog: neither list
|
||||
// mentions them, so both must land behind More rather than vanishing.
|
||||
val l = layout(
|
||||
screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"),
|
||||
subMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
|
||||
)
|
||||
assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds)
|
||||
assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyVisiblePageIsReachableFromSomeMenu() {
|
||||
// Pages beyond the five slots used to vanish instead of overflowing.
|
||||
val l = layout(
|
||||
screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"),
|
||||
subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT")
|
||||
)
|
||||
assertEquals("every page must be reachable", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenPagesAppearInNeitherMenu() {
|
||||
val l = layout(hidden = listOf("Radar", "Map"))
|
||||
assertTrue("Radar" !in l.mainIds && "Radar" !in l.moreIds)
|
||||
assertTrue("Map" !in l.mainIds && "Map" !in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeHidden() {
|
||||
val l = layout(hidden = listOf("Settings"))
|
||||
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBarNeverExceedsFiveSlots() {
|
||||
val l = layout(screenOrder = all)
|
||||
assertTrue("bar had ${l.mainIds.size} slots: ${l.mainIds}", l.mainIds.size <= MenuLayout.MAIN_SLOTS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPageOutOfTheBarPutsItBehindMore() {
|
||||
val moved = MenuLayout.moveToMore("Radar", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("Radar" in l.moreIds)
|
||||
assertTrue("Radar" !in l.mainIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeMovedOffTheBar() {
|
||||
val moved = MenuLayout.moveToMore("Settings", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveIsStableWhenAppliedTwice() {
|
||||
// Persisting what resolve() produced must not change the outcome, or the
|
||||
// settings list and the bar drift apart on the next recomposition.
|
||||
val first = layout()
|
||||
val second = layout(first.mainIds, first.moreIds)
|
||||
assertEquals(first.mainIds, second.mainIds)
|
||||
assertEquals(first.moreIds, second.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPageOntoAFullBarEvictsAnotherPageIntoMore() {
|
||||
// The bar starts full (5 including Settings), so making room must push an
|
||||
// existing page into More instead of dropping it.
|
||||
val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("CwDecode" in l.mainIds)
|
||||
assertEquals("nothing may be lost", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.predict
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import kotlin.math.abs
|
||||
|
||||
class CelestialComputerTest {
|
||||
|
||||
private val observer = GeoPos(22.314066, 108.706575)
|
||||
|
||||
private fun subLunarLongitude(gha: Double) = if (gha <= 180.0) -gha else 360.0 - gha
|
||||
|
||||
@Test
|
||||
fun `moon hour angle stays within one revolution`() {
|
||||
// Sample a full synodic month at 37-minute steps so the sweep crosses
|
||||
// every hour-angle quadrant many times over.
|
||||
var timeMillis = 1786060800_000L // 2026-08-07T00:00:00Z
|
||||
val stepMillis = 37 * 60 * 1000L
|
||||
val endMillis = timeMillis + 30L * 86400_000L
|
||||
var samples = 0
|
||||
while (timeMillis < endMillis) {
|
||||
val gha = CelestialComputer.getMoonPosition(observer, timeMillis).gha
|
||||
assertTrue("gha=$gha out of 0..360 at $timeMillis", gha >= 0.0 && gha < 360.0)
|
||||
val longitude = subLunarLongitude(gha)
|
||||
assertTrue(
|
||||
"sub-lunar longitude=$longitude out of -180..180 at $timeMillis",
|
||||
longitude >= -180.0 && longitude <= 180.0
|
||||
)
|
||||
samples++
|
||||
timeMillis += stepMillis
|
||||
}
|
||||
assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000)
|
||||
}
|
||||
|
||||
private data class RiseSetCase(
|
||||
val name: String,
|
||||
val observer: GeoPos,
|
||||
val startIso: String
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||
val cases = listOf(
|
||||
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||
)
|
||||
|
||||
cases.forEach { testCase ->
|
||||
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||
|
||||
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||
|
||||
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||
|
||||
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||
|
||||
private companion object {
|
||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* clipLon must reduce any longitude into [-180, 180] in bounded time. The old
|
||||
* while-loop never returned for extreme inputs: Infinity stays Infinity after
|
||||
* subtracting 360 (infinite loop), and ~1e12 degree values took billions of
|
||||
* iterations. The modulo rewrite must be bit-equivalent for all finite values.
|
||||
*/
|
||||
class ClipLonTest {
|
||||
|
||||
private fun reduced(v: Double) = ((v + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
|
||||
@Test
|
||||
fun `finite values match the closed interval semantics`() {
|
||||
// The old loop: subtract/add 360 until within (-180, 180] is not quite
|
||||
// it either - 180 stays 180, -180 stays -180. So the interval is closed
|
||||
// on both ends with +180 for positive-boundary hits.
|
||||
assertEquals(-180.0, clipLon(-180.0), 1e-12)
|
||||
assertEquals(180.0, clipLon(180.0), 1e-12)
|
||||
assertEquals(0.0, clipLon(360.0), 1e-12)
|
||||
assertEquals(180.0, clipLon(540.0), 1e-12)
|
||||
assertEquals(-180.0, clipLon(-540.0), 1e-12)
|
||||
assertEquals(-179.999, clipLon(180.001), 1e-9)
|
||||
assertEquals(179.999, clipLon(-180.001), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modulo rewrite is equivalent to the loop across the domain`() {
|
||||
// Sweep the same range the old implementation covered in a probe:
|
||||
// every 0.01 degree from -10000 to 10000 must agree with the reference
|
||||
// reduction to within 1e-9.
|
||||
var v = -10000.0
|
||||
while (v <= 10000.0) {
|
||||
val reference = clipLonByLoop(v)
|
||||
assertEquals(reference, clipLon(v), 1e-9)
|
||||
v += 0.01
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extreme and non-finite inputs return immediately`() {
|
||||
// These used to hang the calling thread (Infinity loop) or take seconds.
|
||||
assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), 0.0)
|
||||
assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), 0.0)
|
||||
assertTrue(clipLon(Double.NaN).isNaN())
|
||||
assertFalse(clipLon(1e15).isNaN())
|
||||
assertTrue(clipLon(1e15) in -180.0..180.0)
|
||||
}
|
||||
|
||||
/** The old while-loop implementation, kept as the reference for equivalence. */
|
||||
private fun clipLonByLoop(longitude: Double): Double {
|
||||
var result = longitude
|
||||
while (result < -180.0) result += 360.0
|
||||
while (result > 180.0) result -= 360.0
|
||||
return minOf(maxOf(result, -180.0), 180.0)
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LotwSatelliteIdsTest {
|
||||
|
||||
/**
|
||||
* Every name the table produces has to exist in the LoTW list verbatim, because LoTW
|
||||
* rejects a QSO whose SAT_NAME is spelled differently - AO7 for AO-7 is refused.
|
||||
*/
|
||||
@Test
|
||||
fun `every mapped name is spelled exactly as LoTW has it`() {
|
||||
val known = LotwSatellites.names
|
||||
for (catnum in catnums) {
|
||||
val name = LotwSatelliteIds.nameFor(catnum)
|
||||
assertTrue("$catnum maps to $name, which LoTW does not list", name in known)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue number is the key precisely so that the source a user happens to fetch
|
||||
* from cannot change the answer. These are the numbers whose names differ most between
|
||||
* Celestrak and AMSAT, so they are the ones worth pinning.
|
||||
*/
|
||||
@Test
|
||||
fun `names that differ between sources resolve to the LoTW spelling`() {
|
||||
assertEquals("AO-91", LotwSatelliteIds.nameFor(43017)) // RADFXSAT (FOX-1B) / AO-91
|
||||
assertEquals("QO-100", LotwSatelliteIds.nameFor(43700)) // ES'HAIL 2 / QO-100
|
||||
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) // TIANYAN 01 / CAS-6 (TO-108)
|
||||
assertEquals("SO-50", LotwSatelliteIds.nameFor(27607)) // SAUDISAT 1C (SO-50) / SO-50
|
||||
assertEquals("AO-123", LotwSatelliteIds.nameFor(61781)) // ASRTU-1 (AO-123) / AO-123
|
||||
}
|
||||
|
||||
/**
|
||||
* ARISS is the station, not its modules. Celestrak's full catalogue lists ISS (UNITY),
|
||||
* (ZVEZDA), (DESTINY) and (NAUKA) as separate objects; matching on the name "ISS" pulled
|
||||
* all of them in, and a QSO cannot be worked through a module.
|
||||
*/
|
||||
@Test
|
||||
fun `ARISS is the station and not one of its modules`() {
|
||||
assertEquals("ARISS", LotwSatelliteIds.nameFor(25544))
|
||||
for (module in listOf(25575, 26400, 26700, 49044)) {
|
||||
assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One source (R4UAB) names 53109 as ROBUSTA 1F while four others call it GREENCUBE
|
||||
* (IO-117), and 53106 appears in that one source alone. Trusting either would have put a
|
||||
* second catalogue number on the same LoTW name.
|
||||
*/
|
||||
@Test
|
||||
fun `IO-117 resolves to the number the amateur sources agree on`() {
|
||||
assertEquals("IO-117", LotwSatelliteIds.nameFor(53109))
|
||||
assertNull(LotwSatelliteIds.nameFor(53106))
|
||||
}
|
||||
|
||||
/** 44879 is TIANQIN 1, catalogued but not an amateur satellite carrying TO-108. */
|
||||
@Test
|
||||
fun `TO-108 does not also match TIANQIN 1`() {
|
||||
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881))
|
||||
assertNull(LotwSatelliteIds.nameFor(44879))
|
||||
}
|
||||
|
||||
/** No two numbers may share a name, or one of them is the wrong satellite. */
|
||||
@Test
|
||||
fun `each LoTW name is claimed by a single catalogue number`() {
|
||||
val names = catnums.mapNotNull { LotwSatelliteIds.nameFor(it) }
|
||||
assertEquals(names.size, names.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isKnown agrees with nameFor`() {
|
||||
assertTrue(LotwSatelliteIds.isKnown(27607))
|
||||
assertFalse(LotwSatelliteIds.isKnown(99999))
|
||||
assertNull(LotwSatelliteIds.nameFor(99999))
|
||||
}
|
||||
|
||||
/** Guards against an edit that empties or truncates the table. */
|
||||
@Test
|
||||
fun `table holds every entry`() {
|
||||
assertEquals(catnums.size, LotwSatelliteIds.size)
|
||||
}
|
||||
|
||||
private val catnums = listOf(
|
||||
7530, 14129, 20439, 20442, 22825, 23439, 24278, 25544, 26609, 26931,
|
||||
27607, 28650, 39444, 40025, 40074, 40908, 40931, 40967, 41847, 43017,
|
||||
43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781
|
||||
)
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Verifies the WaveLog upload payload frequency/band fields.
|
||||
*
|
||||
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
|
||||
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
|
||||
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
|
||||
* value corrupts band derivation.
|
||||
*/
|
||||
class WaveLogApiPayloadTest {
|
||||
|
||||
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
|
||||
private val uplinkHz = 145_850_000L
|
||||
private val downlinkHz = 436_795_000L
|
||||
|
||||
@Test
|
||||
fun v2_freq_usesMhzStringWithMSuffix() {
|
||||
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
|
||||
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000M", freq)
|
||||
assertEquals("436.795000M", freqRx)
|
||||
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
|
||||
val parsedHz = parseLikeWaveLog(freq)
|
||||
assertEquals(uplinkHz, parsedHz)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1_adif_freq_isBareMhzNumber() {
|
||||
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
|
||||
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000", freq)
|
||||
val adifFreq = freq.toDouble() * 1_000_000
|
||||
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1_adif_preservesSixCharacterGrid() {
|
||||
val qso = WavelogQso(
|
||||
id = "test",
|
||||
timeUtcMs = 0L,
|
||||
call = "BG7NTA",
|
||||
mode = "FM",
|
||||
freqTxHz = uplinkHz,
|
||||
freqRxHz = downlinkHz,
|
||||
satName = "SO-50"
|
||||
)
|
||||
|
||||
val adif = WaveLogApi.toAdif(qso, "OM89ab", "SO-50")
|
||||
|
||||
assertTrue(adif.contains("<gridsquare:6>OM89ab"))
|
||||
}
|
||||
|
||||
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
|
||||
private fun parseLikeWaveLog(raw: String): Long {
|
||||
val s = raw.trim()
|
||||
return if (s.endsWith("M", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000_000).toLong()
|
||||
} else if (s.endsWith("k", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000).toLong()
|
||||
} else {
|
||||
s.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
|
||||
// Doppler-corrected values must remain near the base frequency.
|
||||
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
|
||||
val dopplerRate = 7.0 // km/s approaching
|
||||
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
|
||||
assertTrue(
|
||||
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
|
||||
kotlin.math.abs(corrected - uplinkHz) < 20_000
|
||||
)
|
||||
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
|
||||
val mhz = corrected / 1_000_000.0
|
||||
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun band_isRealBand_notSAT() {
|
||||
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
|
||||
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
|
||||
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
|
||||
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
|
||||
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
|
||||
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
|
||||
// Same-band (e.g. simplex) -> empty sat mode
|
||||
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
|
||||
// Never 160m for satellite frequencies
|
||||
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
|
||||
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adif_containsRealBandAndSatMode() {
|
||||
// v2 payload fields (mirror postQso construction)
|
||||
val band = WaveLogApi.bandFromHz(uplinkHz)
|
||||
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
|
||||
assertEquals("2M", band)
|
||||
assertEquals("V/U", satMode)
|
||||
}
|
||||
}
|
||||
+104
-4
@@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
@@ -209,6 +216,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
ElevatedButton(
|
||||
@@ -224,8 +236,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||
fun IconCard(
|
||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
@@ -280,6 +297,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
@@ -329,6 +375,56 @@ fun SharedDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = padding)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||
InfoDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
@@ -337,6 +433,9 @@ private fun DialogShell(
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
val maxSheetHeight = containerHeight - statusBarHeight
|
||||
val stopSheetFling = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
@@ -354,7 +453,7 @@ private fun DialogShell(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
@@ -362,6 +461,7 @@ private fun DialogShell(
|
||||
verticalArrangement = Arrangement.spacedBy(padding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.nestedScroll(stopSheetFling)
|
||||
) {
|
||||
content(padding)
|
||||
|
||||
+3
-9
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat, "Satellites")
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||
|
||||
@Serializable
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
|
||||
data object Radar : Screen(R.drawable.ic_satellite, R.string.nav_radar, "Radar")
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||
@@ -48,18 +48,12 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
||||
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||
|
||||
@Serializable
|
||||
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
||||
data object AmSat : Screen(R.drawable.ic_satellite, R.string.nav_amsat, "AMSAT")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||
}
|
||||
|
||||
// UI settings: default main-menu page order (5 bottom-bar slots)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||
|
||||
// UI settings: default More-menu order (pages behind "More")
|
||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="15"
|
||||
android:viewportHeight="15">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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="@android:color/white"
|
||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||
</vector>
|
||||
+2
-4
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
>
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<path
|
||||
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
File renamed without changes.
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
||||
</vector>
|
||||
@@ -13,12 +13,16 @@
|
||||
<string name="nav_prefs">Ajustes</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Tipo: %s</string>
|
||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
||||
<string name="sat_type_hint">Modos: %s</string>
|
||||
<string name="sat_type_title">Seleccionar modos</string>
|
||||
<string name="sat_search_hint">Id - Nombre</string>
|
||||
<string name="sat_search_clear">Vaciar</string>
|
||||
<string name="sat_clear_all">Vaciar todo</string>
|
||||
<string name="sat_select_all">Seleccionar todo</string>
|
||||
<string name="sat_group_selected">Seleccionados</string>
|
||||
<string name="sat_group_available">Disponibles</string>
|
||||
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||
<string name="sat_warning_title">Alarma\!</string>
|
||||
@@ -26,7 +30,7 @@
|
||||
Esta aplicación incluye más de 9000 satélites.
|
||||
No tiene sentido rastrearlos todos a la vez.
|
||||
\n\nIntenta siempre limitar la lista a los que te
|
||||
interesen mediante la búsqueda y el selector de tipos.</string>
|
||||
interesen mediante la búsqueda y el selector de modos.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
@@ -35,7 +39,7 @@
|
||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||
<string name="pass_modes_title">Tipo de modulación</string>
|
||||
<string name="pass_modes_title">Seleccionar modos</string>
|
||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
||||
<string name="prefs_station_title">Ubicación de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||
<string name="prefs_locator_title">Localizador QTH</string>
|
||||
<string name="prefs_locator_text">Posición por localizador</string>
|
||||
|
||||
<string name="prefs_data_title">Datos satelitales</string>
|
||||
<string name="prefs_data_entries">Satélites: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">Vaciar</string>
|
||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">Salida de datos</string>
|
||||
<string name="prefs_net_output">Red</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Salida de datos de red</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="nav_radiocontrol">Control de radio</string>
|
||||
<string name="rc_settings_title">Control CAT</string>
|
||||
<string name="rc_radio_model">Modelo de radio</string>
|
||||
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||
<string name="rc_enable_switch">Activar CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Salida de red</string>
|
||||
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||
|
||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
<string name="prefs_other_title">Otros</string>
|
||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||
|
||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||
@@ -152,4 +169,11 @@
|
||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Desplazar tonos CW fuera de rango</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica.</string>
|
||||
<string name="amsat_no_report_legend">Sin informes</string>
|
||||
<string name="amsat_no_data_legend">Sin datos</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: una franja por franja de 2 horas</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Activado, cada día son doce franjas de dos horas, así se ve una interrupción dentro del día. Desactivado, cada día es un color y un recuento de informes; el color es el peor estado del día, así que un solo fallo sigue viéndose.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, %3$d informes</string>
|
||||
</resources>
|
||||
@@ -93,12 +93,35 @@
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
|
||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
</string>
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -287,4 +310,11 @@
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
|
||||
<string name="amsat_no_report_legend">Tidak ada laporan</string>
|
||||
<string name="amsat_no_data_legend">Tidak ada data</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, %3$d laporan</string>
|
||||
</resources>
|
||||
@@ -92,12 +92,35 @@
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
|
||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
</string>
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -287,4 +310,11 @@
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
|
||||
<string name="amsat_no_report_legend">Tidak ada laporan</string>
|
||||
<string name="amsat_no_data_legend">Tidak ada data</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, %3$d laporan</string>
|
||||
</resources>
|
||||
@@ -13,19 +13,23 @@
|
||||
<string name="nav_prefs">Настройки</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Выберите тип спутника</string>
|
||||
<string name="sat_type_hint">Режимы: %s</string>
|
||||
<string name="sat_type_title">Выберите режимы</string>
|
||||
<string name="sat_search_hint">Id - Название</string>
|
||||
<string name="sat_search_clear">Очистить</string>
|
||||
<string name="sat_clear_all">Очистить</string>
|
||||
<string name="sat_select_all">Выбрать</string>
|
||||
<string name="sat_group_selected">Выбранные</string>
|
||||
<string name="sat_group_available">Доступные</string>
|
||||
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||
<string name="sat_warning_title">Внимание\!</string>
|
||||
<string name="sat_warning_message">
|
||||
В этом приложении перечислено более 9000 спутников.
|
||||
Отслеживать их все одновременно бессмысленно.
|
||||
\n\nВсегда старайтесь сузить список до тех,
|
||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
||||
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
@@ -34,7 +38,7 @@
|
||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
||||
<string name="pass_modes_title">Выберите режимы</string>
|
||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||
<string name="pass_altitude">Высота: %d км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -95,11 +99,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||
<string name="prefs_station_title">Настройки местоположения</string>
|
||||
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
||||
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
||||
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
||||
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
||||
<string name="prefs_station_title">Положение станции</string>
|
||||
<string name="prefs_station_lat_text">Широта станции</string>
|
||||
<string name="prefs_station_lon_text">Долгота станции</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиция по локатору</string>
|
||||
|
||||
<string name="prefs_data_title">Данные спутников</string>
|
||||
<string name="prefs_data_entries">Спутников: %s</string>
|
||||
@@ -109,16 +113,30 @@
|
||||
<string name="prefs_data_clear">Очистить</string>
|
||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Свои источники</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Вывод данных</string>
|
||||
<string name="prefs_data_output_title">Вывод</string>
|
||||
<string name="prefs_net_output">Сеть</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
||||
<string name="nav_radiocontrol">Управление радио</string>
|
||||
|
||||
<string name="rc_settings_title">CAT управление</string>
|
||||
<string name="rc_radio_model">Модель радио</string>
|
||||
<string name="rc_tx_device_hint">BT адрес TX</string>
|
||||
<string name="rc_rx_device_hint">BT адрес RX</string>
|
||||
<string name="rc_tx_name_hint">Имя радио TX</string>
|
||||
<string name="rc_rx_name_hint">Имя радио RX</string>
|
||||
<string name="rc_enable_switch">Включить CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Сетевой вывод</string>
|
||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||
@@ -126,22 +144,22 @@
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||
|
||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
||||
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
<string name="prefs_other_title">Прочее</string>
|
||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||
@@ -151,4 +169,11 @@
|
||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Сдвигать CW-тоны вне диапазона</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
|
||||
<string name="amsat_no_report_legend">Нет отчётов</string>
|
||||
<string name="amsat_no_data_legend">Нет данных</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: полоса на каждые 2 часа</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Включено — каждый день это двенадцать двухчасовых полос, поэтому перерыв внутри дня виден. Выключено — каждый день это один цвет и число отчётов; цвет соответствует худшему состоянию за день, так что даже один сбой остаётся заметен.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, отчётов: %3$d</string>
|
||||
</resources>
|
||||
@@ -13,19 +13,23 @@
|
||||
<string name="nav_prefs">පසුතල</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
||||
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||
<string name="sat_search_hint">Id - නම</string>
|
||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||
<string name="sat_group_selected">තෝරාගත්</string>
|
||||
<string name="sat_group_available">තිබෙන</string>
|
||||
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||
<string name="sat_warning_message">
|
||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
@@ -35,7 +39,7 @@
|
||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
||||
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
||||
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
||||
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
||||
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
||||
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
||||
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
||||
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
||||
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
||||
<string name="prefs_locator_title">QTH locator</string>
|
||||
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
||||
|
||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_import_satellites_error">චන්ද්රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
|
||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_net_output">ජාලය</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
|
||||
<string name="rc_settings_title">CAT පාලනය</string>
|
||||
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
|
||||
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
|
||||
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
|
||||
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
|
||||
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
|
||||
<string name="rc_enable_switch">CAT සබල කරන්න</string>
|
||||
|
||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_title">Bluetooth ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
<string name="prefs_other_title">වෙනත්</string>
|
||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||
|
||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||
@@ -152,4 +169,11 @@
|
||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">පරාසයෙන් පිටත CW ස්වර මාරු කරන්න</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
|
||||
<string name="amsat_no_report_legend">වාර්තා නැත</string>
|
||||
<string name="amsat_no_data_legend">දත්ත නැත</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: පැය 2 කට එක තීරුවක්</string>
|
||||
<string name="prefs_other_amsat_stripes_help">සක්රිය විට සෑම දිනයක් පැය දෙකේ තීරු දොළහකි, එබැවින් දිනක් තුළ ඇති බිඳවැටීම දැකිය හැක. අක්රිය විට සෑම දිනයක් එක් වර්ණයක් සහ වාර්තා ගණනකි — වර්ණය එදින නරකම තත්ත්වයයි, එබැවින් එක් අසාර්ථකත්වයක් වුවද පෙනේ.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, වාර්තා %3$d</string>
|
||||
</resources>
|
||||
@@ -97,12 +97,35 @@
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* DÜZELTME: CW metni artık kod çözme beklerken yazıyı siliyormuş gibi görünmüyor
|
||||
* YENİ: CW şelalesi tüm ses bandını gösterir, çözücünün ulaşamadığı ton da görünür
|
||||
* YENİ: CW metni yeni içeriği takip eder, geri kaydırdığınızda takibi bırakır
|
||||
* YENİ: ekran okuyucular artık CW şelalesini ve AMSAT gün hücrelerini okuyor
|
||||
* YENİ: CW şelalesi, tonunuz çözücü aralığının dışında olsa bile gerçek yerini işaretler
|
||||
* YENİ: şelalenin altındaki satır tonu adlandırır ve aralığa taşınıp taşınmadığını söyler
|
||||
* YENİ: çözücünün duyamadığı bir ton için CW sinyal göstergesi artık yüksek okumuyor
|
||||
* YENİ: AMSAT gün stilini Ayarlar\'dan seçin - on iki şerit veya rapor sayılı tek renk
|
||||
* YENİ: AMSAT durumu günde 12 iki saatlik şerit gösterir — gün içi kesintiler görünür
|
||||
* YENİ: AMSAT durumu UTC takvim günlerini kullanır, resmi sayfayla eşleşir
|
||||
* YENİ: iki gri, bir yuvanın rapor edilmediğini mi yoksa hiç alınmadığını mı ayırt eder
|
||||
* YENİ: veri kapsamı işareti, daha sessiz uyduların küresel çekimden dışlandığını gösterir
|
||||
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
|
||||
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
|
||||
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
|
||||
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
|
||||
* Daha Fazla menüsü: sağda ince panel, karartma yok
|
||||
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
|
||||
* 64-bit (arm64) desteği geri geldi
|
||||
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
|
||||
</string>
|
||||
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
|
||||
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
|
||||
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
|
||||
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
|
||||
* Düzeltme: radar Doppler hesaplaması artık canlı kayma kaydırıcı değerini kullanıyor
|
||||
* Düzeltme: geçiş ilerleme hesaplaması sıfıra bölme durumuna karşı korumalı
|
||||
* Düzeltme: eşzamanlı calculatePasses çağrıları artık yinelenen hesaplamaları tetiklemiyor
|
||||
* Düzeltme: WaveLog yinelenen QSO gönderimleri ve ızgara kare güncellemesi yarış durumları
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -299,4 +322,11 @@
|
||||
<string name="radar_cw_tone">Ton %1$d Hz</string>
|
||||
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Aralık dışı CW tonlarını kaydır</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez.</string>
|
||||
<string name="amsat_no_report_legend">Rapor yok</string>
|
||||
<string name="amsat_no_data_legend">Veri yok</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: 2 saatlik dilim başına şerit</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Açıkken her gün on iki iki saatlik şerittir, böylece gün içindeki kesinti görünür. Kapalıyken her gün tek renk ve rapor sayısıdır; renk günün en kötü durumudur, yani tek bir arıza bile görünür kalır.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, %3$d rapor</string>
|
||||
</resources>
|
||||
@@ -13,12 +13,16 @@
|
||||
<string name="nav_prefs">Налаштування</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Виберіть тип супутника</string>
|
||||
<string name="sat_type_hint">Режими: %s</string>
|
||||
<string name="sat_type_title">Виберіть режими</string>
|
||||
<string name="sat_search_hint">Id - Назва</string>
|
||||
<string name="sat_search_clear">Очистити</string>
|
||||
<string name="sat_clear_all">Очистити всі</string>
|
||||
<string name="sat_select_all">Вибрати всі</string>
|
||||
<string name="sat_group_selected">Обрані</string>
|
||||
<string name="sat_group_available">Доступні</string>
|
||||
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||
<string name="sat_warning_title">Увага\!</string>
|
||||
@@ -26,7 +30,7 @@
|
||||
У цьому додатку перелічено понад 9000 супутників.
|
||||
Немає сенсу відстежувати їх усі одночасно.
|
||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
@@ -35,7 +39,7 @@
|
||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
||||
<string name="pass_modes_title">Виберіть режими</string>
|
||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||
<string name="pass_altitude">Висота: %.0f км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
||||
<string name="prefs_station_title">Позиція станції</string>
|
||||
<string name="prefs_station_lat_text">Широта станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||
|
||||
<string name="prefs_data_title">Супутникові дані</string>
|
||||
<string name="prefs_data_entries">Супутників: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">Очистити</string>
|
||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">Вивід даних</string>
|
||||
<string name="prefs_net_output">Мережа</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="nav_radiocontrol">Керування радіо</string>
|
||||
<string name="rc_settings_title">CAT керування</string>
|
||||
<string name="rc_radio_model">Модель радіо</string>
|
||||
<string name="rc_tx_device_hint">BT адреса TX</string>
|
||||
<string name="rc_rx_device_hint">BT адреса RX</string>
|
||||
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
||||
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
||||
<string name="rc_enable_switch">Увімкнути CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Мережевий вивід</string>
|
||||
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||
|
||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
<string name="prefs_other_title">Інше</string>
|
||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||
@@ -152,4 +169,11 @@
|
||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Зсувати CW-тони поза діапазоном</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
|
||||
<string name="amsat_no_report_legend">Немає звітів</string>
|
||||
<string name="amsat_no_data_legend">Немає даних</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: смуга на кожні 2 години</string>
|
||||
<string name="prefs_other_amsat_stripes_help">Увімкнено — кожен день це дванадцять двогодинних смуг, тож перерва всередині дня видна. Вимкнено — кожен день це один колір і кількість звітів; колір відповідає найгіршому стану за день, тож навіть один збій залишається помітним.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, звітів: %3$d</string>
|
||||
</resources>
|
||||
@@ -67,6 +67,8 @@
|
||||
<string name="sat_search_clear">清空</string>
|
||||
<string name="sat_clear_all">全部清空</string>
|
||||
<string name="sat_select_all">全选</string>
|
||||
<string name="sat_group_selected_count">已选 (%1$d)</string>
|
||||
<string name="sat_group_available_count">可选 (%1$d)</string>
|
||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||
<string name="sat_warning_title">警告\!</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||
@@ -87,12 +89,35 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 修复: CW 文本框不再出现文字消失一段又补回来的现象
|
||||
* 新: CW 瀑布图显示完整音频频段, 解码范围外的音调也看得见
|
||||
* 新: CW 文本框自动跟随新内容, 往上翻阅时自动停止跟随
|
||||
* 新: 读屏软件现在可以播报 CW 瀑布图与 AMSAT 日格内容
|
||||
* 新: CW 瀑布图标出音调真实位置, 即使它在解码范围之外
|
||||
* 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围
|
||||
* 新: 解码范围外的音调不再让信号强度条显示高值
|
||||
* 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数
|
||||
* 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然
|
||||
* 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致
|
||||
* 新: 两种灰色区分"无人上报"和"无数据"
|
||||
* 新: 数据覆盖标记, 标明被挤掉的卫星数据
|
||||
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
|
||||
* 新: 内置完整版 fp32 模型,解码精度最高
|
||||
* 新: CW 解码历史永久保留(文字不再消失)
|
||||
* CW 瀑布图: 全新 inferno 配色与平滑渐变
|
||||
* 更多菜单: 改为靠右窄面板,不再压暗页面
|
||||
* CW 页面: 移除无效的返回键与误导性状态提示
|
||||
* 恢复 64 位(arm64)支持
|
||||
* 修复: 暂停/恢复不再误报"解码失败"
|
||||
</string>
|
||||
* 修复: 移动页面到主菜单不再导致设置入口永久消失
|
||||
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
|
||||
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
|
||||
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
|
||||
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
|
||||
* 修复: 过境进度计算防除零崩溃
|
||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -283,4 +308,15 @@
|
||||
\n• BG7NTA</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
|
||||
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
|
||||
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">搬移超出范围的 CW 音调</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
|
||||
<string name="amsat_no_report_legend">无人上报</string>
|
||||
<string name="amsat_no_data_legend">无数据</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT:每 2 小时一条纹</string>
|
||||
<string name="prefs_other_amsat_stripes_help">开启时每天画成 12 条两小时条纹,一天之内的中断也看得见。关闭时每天显示一个颜色和报告总数——颜色取当天最差状态,所以出现过一次故障也不会被藏起来。</string>
|
||||
<string name="amsat_day_desc">%1$s,%2$s,%3$d 条报告</string>
|
||||
</resources>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="sat_search_clear">Clear</string>
|
||||
<string name="sat_clear_all">Clear all</string>
|
||||
<string name="sat_select_all">Select all</string>
|
||||
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
||||
<string name="sat_group_available_count">Available (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||
<string name="sat_warning_title">Warning\!</string>
|
||||
<string name="sat_warning_message">
|
||||
@@ -99,12 +101,35 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* FIX: the CW transcript no longer appears to delete text while audio waits to be decoded
|
||||
* NEW: the CW waterfall shows the whole audio band, so a tone the decoder cannot reach is still visible
|
||||
* NEW: the CW transcript follows new text, and stops following as soon as you scroll back
|
||||
* NEW: screen readers now describe the CW waterfall and the AMSAT day cells
|
||||
* NEW: the CW waterfall marks where your tone really is, even when it sits outside the decoder\'s range
|
||||
* NEW: a line under the CW waterfall names the tone and says whether it is being moved into range
|
||||
* NEW: the CW signal meter no longer reads high for a tone the decoder cannot actually hear
|
||||
* NEW: choose the AMSAT day style in Settings - twelve stripes, or one colour with a report count
|
||||
* NEW: AMSAT status shows 12 two-hour stripes per day — outages inside a day are now visible
|
||||
* NEW: AMSAT status uses UTC calendar days, matching the official page
|
||||
* NEW: two greys tell you whether a slot had no report or was never fetched
|
||||
* NEW: a data-coverage marker shows when quieter satellites are crowded out of the global pull
|
||||
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
|
||||
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
|
||||
* NEW: CW decode history is kept permanently (text no longer disappears)
|
||||
* CW waterfall: new inferno colour scheme with smooth gradients
|
||||
* More menu: slim right-hand panel, no dimming overlay
|
||||
* CW page: removed the dead back button and the misleading status line
|
||||
* Restored 64-bit (arm64) support
|
||||
* Fixed: pause/resume no longer shows a spurious "decode failed" error
|
||||
</string>
|
||||
* Fixed: moving pages to main menu no longer hides Settings permanently
|
||||
* Fixed: AMSAT/WavelogLog can now be moved to main menu
|
||||
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
|
||||
* Fixed: radar page now auto-switches to next pass after current pass ends
|
||||
* Fixed: radar Doppler calculation now uses live offset slider value
|
||||
* Fixed: pass progress calculation guards against division by zero
|
||||
* Fixed: concurrent calculatePasses calls no longer trigger duplicate calculations
|
||||
* Fixed: WaveLog duplicate QSO submissions and grid square update race conditions
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -316,4 +341,15 @@
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
<string name="prefs_outro_deepcw" translatable="false">CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine</string>
|
||||
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Shift out-of-range CW tones</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched.</string>
|
||||
<string name="amsat_no_report_legend">No report</string>
|
||||
<string name="amsat_no_data_legend">No data</string>
|
||||
<string name="prefs_other_switch_amsat_stripes">AMSAT: stripe per 2-hour slot</string>
|
||||
<string name="prefs_other_amsat_stripes_help">On, each day is twelve two-hour stripes, so an outage inside a day is visible. Off, each day is one colour and a report count - the colour is the day\'s worst status, so a single failure still shows.</string>
|
||||
<string name="amsat_day_desc">%1$s, %2$s, %3$d reports</string>
|
||||
</resources>
|
||||
@@ -1,2 +1,7 @@
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
||||
* Added custom freq offset setting to network reporting
|
||||
* Added ability to customize data sources via import
|
||||
* Fixed star chart in README by PingouinFerreux (#240)
|
||||
+11
-12
@@ -40,19 +40,18 @@ CPU; timed per-window on device and reported via `lastInferenceMs`).
|
||||
|
||||
## Model
|
||||
|
||||
| | fp32 (original) | int8 (shipped) |
|
||||
|---|---|---|
|
||||
| Size | 15,139,839 bytes | 4,248,808 bytes |
|
||||
| Derivation | — | `quantize_dynamic` (weights → QUInt8, activations float32) |
|
||||
| Input | `spectrogram` [1,1,T,65] float32 | unchanged |
|
||||
| Output | `log_probs` [1,T,42] float32 | unchanged |
|
||||
| In APK | no (available as a release asset) | yes (`assets/deepcw/model.onnx`) |
|
||||
| | Value |
|
||||
|---|---|
|
||||
| File | `assets/deepcw/model.onnx` (fp32, as published upstream) |
|
||||
| Size | 15,139,839 bytes |
|
||||
| Modifications | none — vendored byte-for-byte |
|
||||
| Input | `spectrogram` [1,1,T,65] float32 |
|
||||
| Output | `log_probs` [1,T,42] float32 |
|
||||
|
||||
The int8 model ships inside the APK: it is ~4× smaller and measurably identical
|
||||
to fp32 on synthetic CW at SNR ≥ −4 dB (both degrade together below that). The
|
||||
fp32 model is published as a separate release asset for anyone who wants the
|
||||
highest-fidelity reference. Both are AGPL-3.0-only — see
|
||||
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hashes.
|
||||
The full fp32 model ships inside the APK for maximum decode fidelity. An int8
|
||||
`quantize_dynamic` build was trialled earlier (~4× smaller, measurably identical
|
||||
at SNR ≥ −4 dB) but the shipped artifact is now the unmodified fp32 model. See
|
||||
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hash.
|
||||
|
||||
Audio must be packaged **uncompressed** (`noCompress += "onnx"` in the app
|
||||
module): ONNX Runtime mmap's assets and refuses compressed ones.
|
||||
|
||||
@@ -14,11 +14,9 @@ network model obtained from the DeepCW project.
|
||||
| **License** | GNU Affero General Public License v3.0 only (AGPL-3.0-only) |
|
||||
| **License text** | [`DeepCW-AGPL-3.0.txt`](DeepCW-AGPL-3.0.txt) |
|
||||
| **Obtained at commit** | `8e264d243bbd4467bd19f3f28292219405b47e0e` |
|
||||
| **Original file size** | 15,139,839 bytes |
|
||||
| **Original SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
|
||||
| **Derived file size** | 4,248,808 bytes |
|
||||
| **Derived SHA-256** | `cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98` |
|
||||
| **Derivation** | Dynamic int8 quantization (weights → QUInt8, activations stay float32) via `onnxruntime.quantization.quantize_dynamic`. Input/output names, shapes and dtypes are unchanged. Measured CER on synthetic CW audio is identical to the fp32 model at SNR >= -4 dB; at -6/-8 dB both models degrade similarly. |
|
||||
| **File size** | 15,139,839 bytes |
|
||||
| **SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
|
||||
| **Modifications** | None. The full fp32 model is vendored byte-for-byte as published upstream. |
|
||||
|
||||
Related upstream repositories by the same author (not vendored here):
|
||||
|
||||
@@ -52,7 +50,6 @@ the repository hosting this file.
|
||||
### Reproducing the vendored files
|
||||
|
||||
```bash
|
||||
# 1) Fetch the original fp32 model
|
||||
SHA=8e264d243bbd4467bd19f3f28292219405b47e0e
|
||||
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx
|
||||
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx.json
|
||||
@@ -60,15 +57,7 @@ curl -sL -o DeepCW-AGPL-3.0.txt \
|
||||
https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/LICENSE
|
||||
sha256sum model.onnx
|
||||
# expected: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
|
||||
|
||||
# 2) Reproduce the int8 quantization this repository ships
|
||||
python - <<'PY'
|
||||
from onnxruntime.quantization import quantize_dynamic, QuantType
|
||||
quantize_dynamic("model.onnx", "model_int8.onnx", weight_type=QuantType.QUInt8)
|
||||
PY
|
||||
sha256sum model_int8.onnx
|
||||
# expected: cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98
|
||||
# then copy model_int8.onnx over assets/deepcw/model.onnx
|
||||
# copy model.onnx and model.onnx.json into assets/deepcw/ unchanged
|
||||
```
|
||||
|
||||
## ONNX Runtime
|
||||
|
||||
Binary file not shown.
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -63,8 +64,18 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.R as CoreR
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Scroll slack, in pixels, within which the transcript counts as being at the bottom.
|
||||
*
|
||||
* Not zero: an animated scroll settles a pixel or two short of the maximum, and an exact
|
||||
* comparison would drop out of follow-mode the moment it did.
|
||||
*/
|
||||
private const val AUTOSCROLL_SLACK_PX = 4
|
||||
|
||||
/**
|
||||
* Full-page CW decoder backed by DeepCW.
|
||||
@@ -77,7 +88,7 @@ import com.rtbishop.look4sat.core.presentation.R as CoreR
|
||||
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
|
||||
*/
|
||||
@Composable
|
||||
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
fun CwDecodeScreen() {
|
||||
val context = LocalContext.current
|
||||
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
|
||||
val decoder = remember { container.provideCwDecoder() }
|
||||
@@ -95,9 +106,9 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
|
||||
val decodedText by decoder.decodedText.collectAsState()
|
||||
val historyText by decoder.historyText.collectAsState()
|
||||
val estimatedPitch by decoder.estimatedPitch.collectAsState()
|
||||
val signalStrength by decoder.signalStrength.collectAsState()
|
||||
val inferenceMs by decoder.lastInferenceMs.collectAsState()
|
||||
val detectedToneHz by decoder.detectedToneHz.collectAsState()
|
||||
val activeShiftHz by decoder.activeShiftHz.collectAsState()
|
||||
val errorMessage by decoder.errorMessage.collectAsState()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -139,27 +150,13 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { isListening = false; navigateUp() }) {
|
||||
Icon(
|
||||
painter = painterResource(CoreR.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.cw_back)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.nav_cw),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = if (estimatedPitch != null) {
|
||||
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
|
||||
} else {
|
||||
stringResource(R.string.cw_status_listening)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(CoreR.string.nav_cw),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
IconButton(onClick = { isListening = !isListening }) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
@@ -185,7 +182,34 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
) {
|
||||
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
|
||||
CwWaterfallView(
|
||||
state = waterfall,
|
||||
signalStrength = signalStrength,
|
||||
detectedToneHz = detectedToneHz,
|
||||
toneShiftHz = activeShiftHz
|
||||
)
|
||||
}
|
||||
|
||||
// What the markers cannot say on their own. The waterfall covers only the model's
|
||||
// 400-1200 Hz window, so a tone outside it is missing from the picture entirely -
|
||||
// and with tone shift off there is nothing to mark either. One line of text is
|
||||
// what turns "nothing is happening" into a reason and a remedy.
|
||||
val toneHz = detectedToneHz
|
||||
val hint = when {
|
||||
toneHz == null -> null
|
||||
activeShiftHz != 0f -> stringResource(R.string.cw_tone_shifted_hint, toneHz.roundToInt())
|
||||
CwToneShifter.isInsideWindow(toneHz) -> null
|
||||
else -> stringResource(R.string.cw_tone_outside_hint, toneHz.roundToInt())
|
||||
}
|
||||
if (hint != null) {
|
||||
Text(
|
||||
text = hint,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, top = 4.dp, end = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -207,11 +231,35 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
) {
|
||||
val transcript = (historyText + decodedText).ifEmpty { "…" }
|
||||
val scroll = rememberScrollState()
|
||||
// Follow the newest text, but stop as soon as the operator scrolls away, so
|
||||
// reading back over earlier traffic is not undone by the next decode.
|
||||
//
|
||||
// A boolean rather than comparing position against maxValue: maxValue is
|
||||
// written during layout, after the composition that would read it, so such a
|
||||
// comparison tests the previous frame's height and drifts short of the true
|
||||
// bottom until it latches out of follow-mode altogether.
|
||||
var following by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(scroll) {
|
||||
snapshotFlow { scroll.isScrollInProgress to scroll.value }
|
||||
.collect { (scrolling, value) ->
|
||||
if (scrolling) following = value >= scroll.maxValue - AUTOSCROLL_SLACK_PX
|
||||
}
|
||||
}
|
||||
LaunchedEffect(transcript, following) {
|
||||
if (!following) return@LaunchedEffect
|
||||
// Twice: the first pass lands at the height known when it started, the
|
||||
// second covers growth that arrived while it was animating.
|
||||
repeat(2) {
|
||||
if (scroll.value < scroll.maxValue) scroll.animateScrollTo(scroll.maxValue)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = (historyText + decodedText).ifEmpty { "…" },
|
||||
text = transcript,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scroll)
|
||||
.padding(8.dp),
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
|
||||
@@ -18,19 +18,33 @@
|
||||
package com.rtbishop.look4sat.feature.cw
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Rolling spectrogram history for the waterfall display.
|
||||
@@ -46,6 +60,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
private val lock = Any()
|
||||
private val rows = ArrayDeque<FloatArray>(historyRows)
|
||||
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
||||
// Incremented by clear(). A pushSamples call records the generation before
|
||||
// doing FFT outside the lock and discards its result if a clear occurred in
|
||||
// the meantime, otherwise pre-clear audio would reappear after the button tap.
|
||||
private var generation = 0L
|
||||
|
||||
/**
|
||||
* Bumped on every change so Compose knows to redraw.
|
||||
@@ -66,6 +84,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val audio: FloatArray
|
||||
val generationAtStart: Long
|
||||
synchronized(lock) {
|
||||
pending.ensureCapacity(pending.size + resampled.size)
|
||||
for (sample in resampled) pending.add(sample)
|
||||
@@ -73,84 +92,250 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
|
||||
audio = FloatArray(pending.size) { pending[it] }
|
||||
pending.clear()
|
||||
generationAtStart = generation
|
||||
}
|
||||
|
||||
// FFT outside the lock; only the append below needs exclusivity.
|
||||
val computed = CwDeepSpectrogram.compute(audio)
|
||||
// The whole band, not just the model's window: a tone outside the window leaves no
|
||||
// usable trace inside it, so the narrow view showed the operator nothing at all.
|
||||
val computed = CwDeepSpectrogram.compute(
|
||||
audio,
|
||||
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
|
||||
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
|
||||
)
|
||||
synchronized(lock) {
|
||||
// Drop the result when the user cleared the display while this FFT
|
||||
// was running: those samples belong to the discarded history.
|
||||
if (generation != generationAtStart) return
|
||||
for (row in computed) {
|
||||
if (rows.size >= historyRows) rows.removeFirst()
|
||||
rows.addLast(row)
|
||||
}
|
||||
}
|
||||
_revision.value += 1
|
||||
// update {} not `value += 1`: this runs on the audio capture thread while
|
||||
// clear() runs on the main thread, and `+=` is a non-atomic
|
||||
// read-modify-write. A lost increment means a dropped redraw, and two
|
||||
// writes landing on the same value make StateFlow report no change at all.
|
||||
_revision.update { it + 1 }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
generation++
|
||||
rows.clear()
|
||||
pending.clear()
|
||||
}
|
||||
_revision.value += 1
|
||||
_revision.update { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
|
||||
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
|
||||
*
|
||||
* Spans the whole audio band, not just the model's window, so a tone the decoder cannot
|
||||
* read is still in the picture — inside the window such a tone leaves no usable trace at
|
||||
* all, and the operator could not even tell a signal was present. The window itself is
|
||||
* framed and the rest dimmed, so it stays clear which part is being decoded.
|
||||
*
|
||||
* When [toneShiftHz] is non-zero a tone is being moved into that window: green marks
|
||||
* where it is being delivered, orange marks [detectedToneHz] where the tone really is.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CwWaterfallView(
|
||||
state: CwWaterfallState,
|
||||
signalStrength: Float,
|
||||
detectedToneHz: Float? = null,
|
||||
toneShiftHz: Float = 0f,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val revision by state.revision.collectAsState()
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
// Touch the revision inside the draw scope so a new spectrum triggers a
|
||||
// redraw; without this read the canvas would only ever render once.
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
// A Canvas announces nothing, so the whole spectrum was silent to a screen reader.
|
||||
// The tone and whether the decoder can reach it are the facts the picture conveys,
|
||||
// so they are what the description says.
|
||||
val hz = detectedToneHz?.roundToInt()
|
||||
val toneDesc = when {
|
||||
hz == null || hz <= 0 -> stringResource(R.string.cw_waterfall_idle)
|
||||
toneShiftHz != 0f -> stringResource(R.string.cw_waterfall_shifted, hz)
|
||||
CwToneShifter.isInsideWindow(hz.toFloat()) ->
|
||||
stringResource(R.string.cw_waterfall_inside, hz)
|
||||
else -> stringResource(R.string.cw_waterfall_outside, hz)
|
||||
}
|
||||
val description = stringResource(R.string.cw_waterfall_desc, toneDesc)
|
||||
|
||||
drawRect(color = Color(0xFF00060F), size = size)
|
||||
Box(modifier = modifier.fillMaxSize().semantics { contentDescription = description }) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
// Touch the revision inside the draw scope so a new spectrum triggers a
|
||||
// redraw; without this read the canvas would only ever render once.
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
val rows = state.snapshot()
|
||||
if (rows.isEmpty()) return@Canvas
|
||||
drawRect(color = Color(0xFF00060F), size = size)
|
||||
|
||||
// Scale to the loudest value on screen so quiet signals stay visible.
|
||||
var peak = 0f
|
||||
for (row in rows) for (v in row) if (v > peak) peak = v
|
||||
if (peak <= 0f) return@Canvas
|
||||
val rows = state.snapshot()
|
||||
var peak = 0f
|
||||
// Scale to the loudest value on screen so quiet signals stay visible.
|
||||
for (row in rows) for (v in row) if (v > peak) peak = v
|
||||
|
||||
val rowHeight = size.height / rows.size
|
||||
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
|
||||
if (peak > 0f) {
|
||||
val rowHeight = size.height / rows.size
|
||||
// From the row itself, not the model's bin count: the display spans the
|
||||
// whole band and so carries more bins than the model reads.
|
||||
val binWidth = size.width / rows.first().size
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
val y = index * rowHeight
|
||||
// Linear interpolation between adjacent bins via a horizontal
|
||||
// gradient removes the blocky "pixel" look of discrete columns.
|
||||
for (bin in 0 until row.size - 1) {
|
||||
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
|
||||
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
|
||||
if (m0 < 0.06f && m1 < 0.06f) continue
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))),
|
||||
topLeft = Offset(bin * binWidth, y),
|
||||
size = Size(binWidth + 1f, rowHeight + 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ((index, row) in rows.withIndex()) {
|
||||
val y = index * rowHeight
|
||||
// Linear interpolation between adjacent bins via a horizontal
|
||||
// gradient removes the blocky "pixel" look of 65 discrete columns.
|
||||
for (bin in 0 until row.size - 1) {
|
||||
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
|
||||
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
|
||||
if (m0 < 0.06f && m1 < 0.06f) continue
|
||||
// After the spectrum so it cannot bury them, and outside the `peak > 0`
|
||||
// branch above because a shift stays applied through key-up gaps: the
|
||||
// markers must hold still through them, not blink out whenever the
|
||||
// picture goes momentarily quiet.
|
||||
drawDecoderWindow()
|
||||
drawToneShiftMarkers(detectedToneHz, toneShiftHz)
|
||||
|
||||
if (signalStrength > 0f) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))),
|
||||
topLeft = Offset(bin * binWidth, y),
|
||||
size = Size(binWidth + 1f, rowHeight + 1f)
|
||||
color = Color(0xFF4CD964).copy(alpha = 0.8f),
|
||||
topLeft = Offset(0f, size.height - 3f),
|
||||
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (signalStrength > 0f) {
|
||||
drawRect(
|
||||
color = Color(0xFF4CD964).copy(alpha = 0.8f),
|
||||
topLeft = Offset(0f, size.height - 3f),
|
||||
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
|
||||
// The tone's own frequency, as text: Canvas has no drawText, so this rides on top
|
||||
// of it, on the side the tone lies beyond so it reads with the edge marker.
|
||||
// Suppressed for a non-positive pitch, where the readout is an artefact of the
|
||||
// loudest bin drifting below the shift and printing it would just show nonsense —
|
||||
// the marker itself still shows the low edge.
|
||||
if (toneShiftHz != 0f && detectedToneHz != null && detectedToneHz > 0f) {
|
||||
// Halfway is the tipping point, so the text sits nearer the marker it belongs
|
||||
// to wherever that is — including a pitch on the upper edge, whose line is
|
||||
// drawn hard against the right of the picture.
|
||||
val onHighSide = detectedToneHz > CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ / 2
|
||||
Text(
|
||||
text = "${detectedToneHz.roundToInt()} Hz",
|
||||
fontSize = 9.sp,
|
||||
color = TONE_ORIGIN_COLOUR,
|
||||
modifier = Modifier
|
||||
.align(if (onHighSide) Alignment.TopEnd else Alignment.TopStart)
|
||||
.padding(start = 6.dp, end = 6.dp, top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the shifter actually puts the tone.
|
||||
*
|
||||
* Read from the shifter rather than recomputed as the window midpoint: the two agree
|
||||
* today only by coincidence, and retuning either one would leave this line marking a
|
||||
* frequency nothing is being delivered to — with no test or compiler error to say so.
|
||||
*/
|
||||
private val TONE_SHIFT_TARGET_HZ = CwToneShifter.TARGET_HZ.toFloat()
|
||||
|
||||
private val TONE_TARGET_COLOUR = Color(0xFF4CD964)
|
||||
|
||||
/**
|
||||
* Marker colour for the tone's own frequency.
|
||||
*
|
||||
* Cyan, not the orange it used to be: the inferno ramp runs black through purple and
|
||||
* orange to pale yellow, so an orange marker sitting on the very trace it points at was
|
||||
* the same hue as that trace and could not be told apart from it. Cyan appears nowhere in
|
||||
* the ramp.
|
||||
*/
|
||||
private val TONE_ORIGIN_COLOUR = Color(0xFF00E5FF)
|
||||
|
||||
/**
|
||||
* Shades the part of the band the model does not read, and marks the tone within it.
|
||||
*
|
||||
* The picture spans the whole band while the decoder reads only a window of it, so without
|
||||
* this the operator cannot tell which half of what they are looking at is being decoded.
|
||||
*/
|
||||
private fun DrawScope.drawDecoderWindow() {
|
||||
val loX = hzToX(CwDeepSpectrogram.MIN_FREQ_HZ.toFloat()) * size.width
|
||||
val hiX = hzToX(CwDeepSpectrogram.MAX_FREQ_HZ.toFloat()) * size.width
|
||||
// Lift the readable band rather than darken the rest. The background is already almost
|
||||
// black, so a dim wash over it moves only a couple of levels and reads as nothing; a
|
||||
// faint lift inside is visible against it while leaving the trace itself untouched.
|
||||
drawRect(
|
||||
color = Color(0xFF7FA8D8).copy(alpha = 0.16f),
|
||||
topLeft = Offset(loX, 0f),
|
||||
size = Size(hiX - loX, size.height)
|
||||
)
|
||||
val edge = Color(0xFF8FA6C4).copy(alpha = 0.8f)
|
||||
drawRect(color = edge, topLeft = Offset(loX, 0f), size = Size(1.5f, size.height))
|
||||
drawRect(color = edge, topLeft = Offset(hiX - 1.5f, 0f), size = Size(1.5f, size.height))
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks where the shifter is delivering the tone, and where the tone really is.
|
||||
*
|
||||
* Draws nothing when no shift is applied: the tone is then inside the window, plainly
|
||||
* visible in the spectrum on its own, and a marker would only add clutter.
|
||||
*/
|
||||
private fun DrawScope.drawToneShiftMarkers(detectedToneHz: Float?, toneShiftHz: Float) {
|
||||
if (toneShiftHz == 0f) return
|
||||
|
||||
// The target line is drawn on the strength of the shift alone. A shift being applied
|
||||
// is the fact worth showing, and it must not depend on the tone readout, which can be
|
||||
// absent for a weak or slow fist even while a shift stays latched from an earlier scan.
|
||||
markerBracket(hzToX(TONE_SHIFT_TARGET_HZ), TONE_TARGET_COLOUR)
|
||||
|
||||
// No usable tone estimate: the target line alone, rather than a guessed position.
|
||||
if (detectedToneHz == null || detectedToneHz.isNaN() || detectedToneHz <= 0f) return
|
||||
|
||||
// The tone is genuinely in the picture now, so mark it where it is.
|
||||
markerBracket(hzToX(detectedToneHz), TONE_ORIGIN_COLOUR)
|
||||
}
|
||||
|
||||
/** Height of the strip along the top reserved for frequency markers. */
|
||||
private const val MARKER_GUTTER_PX = 7f
|
||||
|
||||
/**
|
||||
* A marker pip in the gutter above the spectrum, at [fraction] across.
|
||||
*
|
||||
* Kept out of the spectrum rather than drawn across it. A line laid over a CW trace cannot
|
||||
* be told apart from the keying gaps in that trace, and the marker that matters most sits
|
||||
* exactly on the tone it points at - so it was invisible in the one place it was needed.
|
||||
* A pip in its own strip is clear of the signal and still reads against the axis.
|
||||
*/
|
||||
private fun DrawScope.markerBracket(fraction: Float, colour: Color) {
|
||||
val x = (fraction * size.width).coerceIn(1f, size.width - 3f)
|
||||
drawRect(
|
||||
color = colour,
|
||||
topLeft = Offset(x - 1f, 0f),
|
||||
size = Size(3f, MARKER_GUTTER_PX)
|
||||
)
|
||||
// A short stub reaching into the spectrum, so the pip reads as pointing at a
|
||||
// frequency rather than floating above one, without masking the trace below.
|
||||
drawRect(
|
||||
color = colour.copy(alpha = 0.55f),
|
||||
topLeft = Offset(x, MARKER_GUTTER_PX),
|
||||
size = Size(1f, MARKER_GUTTER_PX * 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
/** Fraction across the display for [hz], 0..1 spanning the visible band. */
|
||||
private fun hzToX(hz: Float): Float {
|
||||
val lo = CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ.toFloat()
|
||||
val hi = CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ.toFloat()
|
||||
return (hz - lo) / (hi - lo)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
|
||||
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_tone_shifted_hint">Tono de %1$d Hz trasladado a la ventana de decodificación de 400-1200 Hz</string>
|
||||
<string name="cw_tone_outside_hint">El tono de %1$d Hz está fuera de la ventana de decodificación de 400-1200 Hz. Active el desplazamiento de tono en Ajustes.</string>
|
||||
<string name="cw_waterfall_desc">Espectro de cascada, %1$s</string>
|
||||
<string name="cw_waterfall_idle">aún sin señal</string>
|
||||
<string name="cw_waterfall_shifted">tono de %1$d hercios, trasladado al rango de decodificación</string>
|
||||
<string name="cw_waterfall_outside">tono de %1$d hercios, fuera del rango de decodificación</string>
|
||||
<string name="cw_waterfall_inside">tono de %1$d hercios</string>
|
||||
</resources>
|
||||
@@ -1,12 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
|
||||
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
|
||||
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
|
||||
<string name="cw_waterfall_idle">belum ada sinyal</string>
|
||||
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
||||
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
||||
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
||||
</resources>
|
||||
@@ -1,12 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
|
||||
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
|
||||
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
|
||||
<string name="cw_waterfall_idle">belum ada sinyal</string>
|
||||
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
||||
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
||||
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_tone_shifted_hint">Тон %1$d Гц перенесён в окно декодирования 400-1200 Гц</string>
|
||||
<string name="cw_tone_outside_hint">Тон %1$d Гц находится вне окна декодирования 400-1200 Гц. Включите сдвиг тона в настройках.</string>
|
||||
<string name="cw_waterfall_desc">Водопадный спектр, %1$s</string>
|
||||
<string name="cw_waterfall_idle">сигнала пока нет</string>
|
||||
<string name="cw_waterfall_shifted">тон %1$d герц, перенесён в диапазон декодирования</string>
|
||||
<string name="cw_waterfall_outside">тон %1$d герц, вне диапазона декодирования</string>
|
||||
<string name="cw_waterfall_inside">тон %1$d герц</string>
|
||||
</resources>
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user