Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac45ed0efb | ||
|
|
96bbb022e8 | ||
|
|
b6753a4fa6 | ||
|
|
0889a3bd88 | ||
|
|
10c415fabd | ||
|
|
984a139a81 | ||
|
|
4cb03111bc | ||
|
|
23f47d9122 | ||
|
|
5a45aab2b1 | ||
|
|
9367878702 | ||
|
|
8fbc639a82 | ||
|
|
fa73328936 | ||
|
|
50a644f417 | ||
|
|
7a2bbb8701 | ||
|
|
018a3afd2b | ||
|
|
3612e662e7 | ||
|
|
79215e7623 | ||
|
|
8f646d76f9 | ||
|
|
ea125d7db4 | ||
|
|
fdb44af9ff | ||
|
|
f6db55b35c | ||
|
|
1b8f8c46f6 | ||
|
|
9798107d37 |
No files matched your search
@@ -110,6 +110,10 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
- 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 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
|
||||
|
||||
|
||||
@@ -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,6 +72,32 @@ 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("")
|
||||
@@ -71,6 +109,12 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
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 +139,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,7 +243,8 @@ 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.
|
||||
@@ -237,6 +307,140 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -322,10 +526,24 @@ 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() {
|
||||
@@ -334,8 +552,19 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
_historyText.value = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
_signalStrength.value = 0f
|
||||
_lastInferenceMs.value = 0
|
||||
// Re-detect from scratch: the operator may have retuned before resetting.
|
||||
_activeShiftHz.value = 0f
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
streamingShifter.reset()
|
||||
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
|
||||
// current setting instead of reporting a spurious change.
|
||||
toneShiftWasEnabled = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
||||
@@ -111,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)
|
||||
|
||||
|
||||
+117
-22
@@ -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,
|
||||
@@ -46,7 +51,20 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
|
||||
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 */
|
||||
@@ -80,6 +98,33 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -89,33 +134,73 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (5 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"))
|
||||
|
||||
// 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 = (nowSec - d * 86400L) * 1000
|
||||
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 36).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 3).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)
|
||||
}
|
||||
@@ -154,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
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,8 @@ class SettingsRepo(
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keyCwToneShiftEnabled = "cwToneShiftEnabled"
|
||||
private val keyAmsatDayStripes = "amsatDayStripes"
|
||||
|
||||
private val separatorComma = ","
|
||||
|
||||
@@ -405,6 +407,8 @@ class SettingsRepo(
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
|
||||
putBoolean(keyAmsatDayStripes, new.amsatDayStripes)
|
||||
|
||||
}
|
||||
new
|
||||
@@ -431,7 +435,9 @@ class SettingsRepo(
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f),
|
||||
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false),
|
||||
amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true)
|
||||
)
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ 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
|
||||
@@ -87,7 +87,7 @@ 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
|
||||
@@ -101,4 +101,22 @@ class RemoteSource(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,8 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
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 {
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ data class SatDay(
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 5 days of state */
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 5 天(新→旧)
|
||||
val days: List<SatDay>, // 3 天(新→旧)
|
||||
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -76,7 +76,22 @@ data class OtherSettings(
|
||||
val wavelogAutoUpload: Boolean = false,
|
||||
// Upstream radar compass offset (merged from rt-bishop)
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: 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(
|
||||
|
||||
@@ -28,4 +28,9 @@ interface IRemoteSource {
|
||||
|
||||
/** 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?
|
||||
}
|
||||
+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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,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,6 +93,17 @@
|
||||
\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">
|
||||
* 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)
|
||||
@@ -109,7 +120,7 @@
|
||||
* 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>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -298,4 +309,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,6 +92,17 @@
|
||||
\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">
|
||||
* 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)
|
||||
@@ -108,7 +119,7 @@
|
||||
* 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>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -298,4 +309,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>
|
||||
@@ -169,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>
|
||||
@@ -169,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,6 +97,17 @@
|
||||
\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">
|
||||
* 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)
|
||||
@@ -113,7 +124,7 @@
|
||||
* 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>
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -310,4 +321,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>
|
||||
@@ -169,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>
|
||||
@@ -89,6 +89,17 @@
|
||||
<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 瀑布图与 AMSAT 日格内容
|
||||
* 新: CW 瀑布图标出音调真实位置, 即使它在解码范围之外
|
||||
* 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围
|
||||
* 新: 解码范围外的音调不再让信号强度条显示高值
|
||||
* 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数
|
||||
* 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然
|
||||
* 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致
|
||||
* 新: 两种灰色区分"无人上报"和"无数据"
|
||||
* 新: 数据覆盖标记, 标明被挤掉的卫星数据
|
||||
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
|
||||
* 新: 内置完整版 fp32 模型,解码精度最高
|
||||
* 新: CW 解码历史永久保留(文字不再消失)
|
||||
@@ -105,7 +116,7 @@
|
||||
* 修复: 过境进度计算防除零崩溃
|
||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
||||
</string>
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -300,4 +311,11 @@
|
||||
<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>
|
||||
@@ -101,6 +101,17 @@
|
||||
\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">
|
||||
* 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)
|
||||
@@ -117,7 +128,7 @@
|
||||
* 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>
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -333,4 +344,11 @@
|
||||
<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>
|
||||
@@ -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.
|
||||
@@ -96,6 +107,8 @@ fun CwDecodeScreen() {
|
||||
val decodedText by decoder.decodedText.collectAsState()
|
||||
val historyText by decoder.historyText.collectAsState()
|
||||
val signalStrength by decoder.signalStrength.collectAsState()
|
||||
val detectedToneHz by decoder.detectedToneHz.collectAsState()
|
||||
val activeShiftHz by decoder.activeShiftHz.collectAsState()
|
||||
val errorMessage by decoder.errorMessage.collectAsState()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -169,7 +182,34 @@ fun CwDecodeScreen() {
|
||||
.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(
|
||||
@@ -191,11 +231,35 @@ fun CwDecodeScreen() {
|
||||
.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,20 +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.
|
||||
@@ -83,7 +96,13 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -113,59 +132,210 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
/**
|
||||
* 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>
|
||||
@@ -6,4 +6,11 @@
|
||||
<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>
|
||||
@@ -6,4 +6,11 @@
|
||||
<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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_tone_shifted_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවට ගෙන ගියා</string>
|
||||
<string name="cw_tone_outside_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවෙන් පිටත. සැකසුම් තුළ ස්වර මාරුව සක්රිය කරන්න.</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>
|
||||
@@ -6,4 +6,11 @@
|
||||
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
|
||||
<string name="cw_grant_permission">İzin ver</string>
|
||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||
<string name="cw_tone_shifted_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığına taşındı</string>
|
||||
<string name="cw_tone_outside_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığının dışında. Ayarlar\'dan ton kaydırmayı açın.</string>
|
||||
<string name="cw_waterfall_desc">Şelale spektrumu, %1$s</string>
|
||||
<string name="cw_waterfall_idle">henüz sinyal yok</string>
|
||||
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
|
||||
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
|
||||
<string name="cw_waterfall_inside">ton %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>
|
||||
@@ -6,4 +6,11 @@
|
||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||
<string name="cw_grant_permission">授予权限</string>
|
||||
<string name="cw_open_settings">打开应用设置</string>
|
||||
<string name="cw_tone_shifted_hint">已将 %1$d Hz 音调搬入 400-1200 Hz 解码范围</string>
|
||||
<string name="cw_tone_outside_hint">%1$d Hz 音调在 400-1200 Hz 解码范围外,请在设置中开启音调搬移。</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>
|
||||
@@ -6,4 +6,11 @@
|
||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||
<string name="cw_grant_permission">Grant permission</string>
|
||||
<string name="cw_open_settings">Open app settings</string>
|
||||
<string name="cw_tone_shifted_hint">Tone %1$d Hz moved into the 400-1200 Hz decoder window</string>
|
||||
<string name="cw_tone_outside_hint">Tone %1$d Hz is outside the 400-1200 Hz decoder window. Enable tone shift in Settings.</string>
|
||||
<string name="cw_waterfall_desc">Waterfall spectrum, %1$s</string>
|
||||
<string name="cw_waterfall_idle">no signal yet</string>
|
||||
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
|
||||
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
|
||||
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
|
||||
</resources>
|
||||
+20
@@ -636,6 +636,26 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// CW decoding: shift out-of-window tones into the model's 400-1200 Hz window
|
||||
SwitchRow(R.string.prefs_other_switch_cw_tone_shift, settings.cwToneShiftEnabled) {
|
||||
onAction(SettingsAction.ToggleCwToneShift(it))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_other_cw_tone_shift_help),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// AMSAT status: twelve two-hour stripes per day, or one colour for the day
|
||||
SwitchRow(R.string.prefs_other_switch_amsat_stripes, settings.amsatDayStripes) {
|
||||
onAction(SettingsAction.ToggleAmsatDayStripes(it))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_other_amsat_stripes_help),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Compass calibration sliders at the bottom
|
||||
CompassOffsetRow(
|
||||
labelResId = R.string.prefs_other_compass_offset,
|
||||
|
||||
@@ -61,6 +61,10 @@ sealed interface SettingsAction {
|
||||
// Toggles
|
||||
data class ToggleUtc(val value: Boolean) : SettingsAction
|
||||
data class ToggleUpdate(val value: Boolean) : SettingsAction
|
||||
|
||||
/** Shift CW tones outside the model's 400-1200 Hz window into it before decoding. */
|
||||
data class ToggleCwToneShift(val value: Boolean) : SettingsAction
|
||||
data class ToggleAmsatDayStripes(val value: Boolean) : SettingsAction
|
||||
data class ToggleSweep(val value: Boolean) : SettingsAction
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
|
||||
+2
@@ -127,6 +127,8 @@ class SettingsViewModel(
|
||||
// Toggles
|
||||
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
|
||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||
is SettingsAction.ToggleCwToneShift -> settingsRepo.updateOtherSettings { it.copy(cwToneShiftEnabled = action.value) }
|
||||
is SettingsAction.ToggleAmsatDayStripes -> settingsRepo.updateOtherSettings { it.copy(amsatDayStripes = action.value) }
|
||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
|
||||
+143
-14
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -35,9 +36,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -48,7 +52,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
@@ -114,7 +117,11 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(uiState.statuses, key = { it.name }) { status ->
|
||||
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
|
||||
StatusRow(
|
||||
status = status,
|
||||
stripes = uiState.dayStripes,
|
||||
onClickDay = { day -> selectedDay = status to day }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,11 +174,16 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh:
|
||||
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
|
||||
@Composable
|
||||
private fun LegendRow() {
|
||||
// The two greys are listed because most of a typical grid is grey: measured live, 81%
|
||||
// of cells were "nobody reported" and 11% were outside the data we received. Without
|
||||
// the distinction a mostly-grey row reads as a dead satellite.
|
||||
val legend = listOf(
|
||||
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
|
||||
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
|
||||
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
||||
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
|
||||
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100),
|
||||
stringResource(id = R.string.amsat_no_report_legend) to Color(0xFFC0C0C0),
|
||||
stringResource(id = R.string.amsat_no_data_legend) to Color(0xFFE8E8E8)
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
|
||||
@@ -234,8 +246,7 @@ private fun HeaderRow(statuses: List<SatStatus>) {
|
||||
|
||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||
@Composable
|
||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
private fun StatusRow(status: SatStatus, stripes: Boolean, onClickDay: (SatDay) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -247,10 +258,25 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
// When the global report pull is incomplete, show how many we actually got
|
||||
// versus what the summary endpoint says exists. The summary is a single extra
|
||||
// request, so this is honest without the 88-request cost of per-satellite pulls.
|
||||
if (status.summaryCount > 0) {
|
||||
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||
if (actual < status.summaryCount) {
|
||||
Text(
|
||||
text = "$actual / ${status.summaryCount}",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF888888),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
status.days.forEach { day ->
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
DayCell(
|
||||
slot = slot,
|
||||
day = day,
|
||||
stripes = stripes,
|
||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||
onClick = { onClickDay(day) }
|
||||
)
|
||||
@@ -258,20 +284,123 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||
private const val NO_REPORT_COLOUR = 0xFFC0C0C0
|
||||
|
||||
/**
|
||||
* Status colours worst first, for collapsing a day to one of them.
|
||||
*
|
||||
* Ordered by how much the operator needs to know about it: a reported failure outranks a
|
||||
* partial contact, which outranks a success, and both greys come last because they are
|
||||
* absences rather than observations. Duplicated from AmSatRepository, which owns these
|
||||
* literals — see the note in AGENTS.md.
|
||||
*/
|
||||
private val SEVERITY = listOf(
|
||||
0xFFDC267F, // not heard
|
||||
0xFFFE6100, // unrecognised status
|
||||
0xFFFFB000, // telemetry only
|
||||
0xFF648FFF, // heard
|
||||
NO_REPORT_COLOUR,
|
||||
0xFFE8E8E8 // no data fetched
|
||||
)
|
||||
|
||||
/**
|
||||
* Black or white for text on [background], whichever reads better.
|
||||
*
|
||||
* White is not safe on all of the status colours: on the telemetry amber it measures
|
||||
* 1.83:1 against WCAG's 3:1 for large text, and that cell does carry a count whenever a
|
||||
* day held nothing but telemetry reports. Relative luminance decides it instead.
|
||||
*/
|
||||
private fun readableOn(background: Long): Color {
|
||||
val colour = Color(background)
|
||||
return if (colour.luminance() > 0.4f) Color(0xFF1A1A1A) else Color.White
|
||||
}
|
||||
|
||||
/** The day's worst status colour, used for both the flat tile and the spoken summary. */
|
||||
private fun worstStatusColour(day: SatDay): Long =
|
||||
day.slots.map { it.statusColor }
|
||||
.minByOrNull { SEVERITY.indexOf(it).takeIf { i -> i >= 0 } ?: SEVERITY.size }
|
||||
?: NO_REPORT_COLOUR
|
||||
|
||||
/** Legend string for a status colour, so the grid speaks the same words the legend shows. */
|
||||
private fun statusLabel(colour: Long): Int = when (colour) {
|
||||
0xFF648FFF -> R.string.amsat_active
|
||||
0xFFFFB000 -> R.string.amsat_tlm
|
||||
0xFFDC267F -> R.string.amsat_not_heard
|
||||
0xFFFE6100 -> R.string.amsat_conflict
|
||||
0xFFE8E8E8 -> R.string.amsat_no_data_legend
|
||||
else -> R.string.amsat_no_report_legend
|
||||
}
|
||||
|
||||
/**
|
||||
* One day as a stripe per two-hour slot.
|
||||
*
|
||||
* Showing a single colour per day hid the shape of the day: a satellite that worked all
|
||||
* morning and failed all afternoon looked identical to one that worked once. At 64 dp
|
||||
* across, twelve stripes are about 5 dp each - roughly 15 px on a 440 dpi screen - and
|
||||
* runs of the same status merge visually, so a typical day reads as a few blocks rather
|
||||
* than twelve thin lines.
|
||||
*
|
||||
* Stripes run newest-first, left to right, matching both the slot order the repository
|
||||
* produces and the day columns in the header. Time therefore flows right to left within
|
||||
* a cell, which is the opposite of the usual convention but consistent with the rest of
|
||||
* the grid.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
val color = Color(slot.statusColor)
|
||||
private fun DayCell(day: SatDay, stripes: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
// Coloured Boxes announce nothing, so the grid - the entire content of this page -
|
||||
// was silent to a screen reader. The worst status and the report count are what the
|
||||
// cell conveys either way, and they are also what the tap dialog then expands on.
|
||||
val worst = worstStatusColour(day)
|
||||
val total = day.slots.sumOf { it.count }
|
||||
val description = stringResource(
|
||||
R.string.amsat_day_desc, day.dateLabel, stringResource(statusLabel(worst)), total
|
||||
)
|
||||
// 28 dp, below the 48 dp minimum touch target. Raising it to 48 dp measured a 71%
|
||||
// increase in row pitch - 14 satellites per screen down to 8 on a 6.1" phone - and
|
||||
// comparing many satellites at a glance is what this page is for. Compose cannot
|
||||
// extend a touch target past the layout bounds, so the two cannot both be had here.
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.semantics(mergeDescendants = true) { contentDescription = description }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (slot.count > 0) {
|
||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
val tile = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
|
||||
if (stripes) {
|
||||
Row(modifier = tile) {
|
||||
day.slots.forEach { slot ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(Color(slot.statusColor))
|
||||
)
|
||||
}
|
||||
}
|
||||
return@Box
|
||||
}
|
||||
|
||||
// One colour for the whole day, for operators who preferred the original tile.
|
||||
// The colour is the day's worst status rather than its first reported one: picking
|
||||
// the first hid outages behind an earlier good report, which is what the stripes
|
||||
// were introduced to expose, and a summary that hides bad news is worse than none.
|
||||
Box(
|
||||
modifier = tile.background(Color(worst)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (total > 0) {
|
||||
Text(
|
||||
text = total.toString(),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = readableOn(worst)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -8,6 +8,7 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -19,17 +20,33 @@ data class SatStatusUiState(
|
||||
val statuses: List<SatStatus> = emptyList(),
|
||||
val reports: Map<String, SatReport> = emptyMap(),
|
||||
val fetchedAtUtcMs: Long = 0L,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
/** Draw each day as twelve two-hour stripes rather than a single colour. */
|
||||
val dayStripes: Boolean = true
|
||||
)
|
||||
|
||||
class SatStatusViewModel(
|
||||
private val amSatRepo: IAmSatRepository
|
||||
private val amSatRepo: IAmSatRepository,
|
||||
settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||
private val _uiState = MutableStateFlow(
|
||||
SatStatusUiState(
|
||||
isLoading = true,
|
||||
dayStripes = settingsRepo.otherSettings.value.amsatDayStripes
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||
|
||||
init {
|
||||
// Collected rather than read once: the switch lives in Settings, so the operator
|
||||
// is on another screen when they change it and would otherwise come back to the
|
||||
// old style until the page was rebuilt.
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collect { other ->
|
||||
_uiState.update { it.copy(dayStripes = other.amsatDayStripes) }
|
||||
}
|
||||
}
|
||||
fetch()
|
||||
}
|
||||
|
||||
@@ -88,7 +105,7 @@ class SatStatusViewModel(
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
SatStatusViewModel(container.amSatRepo)
|
||||
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "464"
|
||||
appVersionCode = "465"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.5.7"
|
||||
appVersionName = "4.5.8"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user