Compare commits

...
4 Commits
Author SHA1 Message Date
mckero bdc6db5aff build: bump to 4.6.0 (versionCode 467)
Carries the transcript-stall fix, which was committed but never pushed - v4.5.9 was
tagged at the version-bump commit before it, so the APK users have does not contain
it and their history box still appears to delete text.

Release notes gain one line in the five locales that carry them, describing that fix.
2026-08-25 06:18:59 +00:00
mckero 92499b1cf1 feat(wavelog): map NORAD catalogue numbers to LoTW satellite names
LoTW refuses a QSO whose SAT_NAME is not spelled as in its accepted list - its own
help page gives AO7 against AO-7 as a rejection - so the name we upload has to match
exactly. The existing code derives that name with substringBefore('('), which returns
the descriptive part of a TLE name rather than the OSCAR designator: measured against
live Celestrak amateur data, 0 of 96 satellites resolved to a name LoTW accepts.
ASRTU-1 uploads as ASRTU-1 where LoTW wants AO-123.

Keying on the name cannot be made to work, because sources disagree. Of the 49
satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
differently - 43017 is RADFXSAT (FOX-1B) in one and AO-91 in the other, 43700 is
ES'HAIL 2 against QO-100 - so which name a user gets depends on the source they
happen to fetch from. The NORAD catalogue number is identical everywhere, so this
table is keyed on it.

Coverage is 29 entries, not the 112 names LoTW lists, because the rest are satellites
no source still carries: they have re-entered, no user can track them, and a mapping
for them would never be consulted. Every number was read out of live TLE data from the
app's own configured sources rather than typed from memory - a first attempt at writing
them by hand had AO-123 as 62690 when it is 61781.

Three names matched more than one catalogued object and were settled by which object
the amateur-specific sources carry. ARISS is 25544, the station; the full catalogue
also lists ISS (UNITY), (ZVEZDA), (DESTINY) and (NAUKA), which are modules. IO-117 is
53109, named GREENCUBE (IO-117) by four sources against R4UAB alone calling it
ROBUSTA 1F. TO-108 is 44881, in all three amateur sources, where 44879 is TIANQIN 1.

Not yet wired into the upload path: WavelogQso carries only a satellite name, so the
catalogue number has to be threaded through from the radar screen first. This commit
adds the table and its tests only, leaving behaviour unchanged.
2026-08-25 06:16:08 +00:00
mckero 828fd0fb6f fix(cw): stop the transcript stalling while audio waits to be archived
The history box appeared to delete text. Audio leaving the 20 s live window is
decoded into the archive only once a full 15 s batch has accumulated, so until
then its characters were in neither place: not in the live decode, which had
scrolled past them, and not in the history, which had not seen them yet.

Measured on a 20 WPM timeline, the concatenated transcript held at 40 characters
from t=24 s to t=34.5 s - eleven seconds of no growth - then jumped to 70 when the
batch flushed. Up to 30 characters sat in that gap. Reading it as deletion is
reasonable; the text really was missing from the box.

The pending batch is now decoded too, on the same 1.5 s cycle as the live window,
and shown as a provisional tail after the committed text. The final archive decode
replaces it, having the whole batch for context. The transcript is monotonic
afterwards: +3 characters every cycle with no stalls.

Decoding each 100 ms capture chunk instead would have removed the gap entirely but
measured 14x the inference load - over 250% of one core across ten minutes - and a
chunk that short carries under two dot-lengths of context, so the decode would be
poor as well as expensive. One extra inference per redecode cycle costs 24.7%
against 18.3%.

Discarding buffered audio drops the provisional text with it, since that text
describes audio that no longer exists. Committed text stays: it was correct for
audio that really was archived.
2026-08-23 11:40:22 +00:00
mckero 07df22d287 build: bump to 4.5.9 (versionCode 466)
The v4.5.8 tag was already published against the version-bump commit alone, so
the twelve commits of actual work had no release to land in - moving the tag made
the CI job fail on an existing release rather than replacing it. A new version
number is the right way round, per the project's own rule against re-cutting a
tag.

Release notes are unchanged: the five locales already describe exactly what these
commits contain.
2026-08-23 11:06:21 +00:00
9 changed files with 220 additions and 5 deletions

No files matched your search

@@ -103,9 +103,24 @@ class CwDeepDecoder(
private val _decodedText = MutableStateFlow("") private val _decodedText = MutableStateFlow("")
override val decodedText: StateFlow<String> = _decodedText.asStateFlow() override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
/**
* Archived text, plus a provisional decode of audio not yet archived.
*
* Kept as one flow of the two parts concatenated. Without the provisional part the
* transcript visibly shrank: audio leaving the 20 s window waits for a full
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
* characters were in neither place - measured, up to 30 characters at 20 WPM would
* vanish and reappear later, which reads as the box deleting text.
*/
private val _historyText = MutableStateFlow("") private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow() override val historyText: StateFlow<String> = _historyText.asStateFlow()
/** Permanently archived text; the provisional tail is appended to this for display. */
private var committedText = ""
/** Provisional decode of the pending archive batch, replaced when it is archived. */
private var pendingText = ""
private val _estimatedPitch = MutableStateFlow<Float?>(null) private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow() override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
@@ -373,6 +388,10 @@ class CwDeepDecoder(
private fun dropBufferedAudio() { private fun dropBufferedAudio() {
buffer.reset() buffer.reset()
archiveSize = 0 archiveSize = 0
// The provisional text describes audio being discarded, so it goes with it.
// Committed text stays: it was correct for audio that really was archived.
pendingText = ""
_historyText.value = committedText
} }
/** /**
@@ -467,9 +486,27 @@ class CwDeepDecoder(
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio) val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram) val text = runInference(activeSession, activeEnvironment, spectrogram)
if (text.isNotEmpty()) { // This batch is final, so its provisional decode is superseded rather than kept.
_historyText.value += text committedText += text
} pendingText = ""
_historyText.value = committedText
}
/**
* Decode the audio waiting to be archived, so it stays on screen until it is.
*
* Provisional: the batch is still growing, and the final decode sees all of it at once
* with more context. Runs on the redecode cycle rather than per capture chunk -
* decoding every 100 ms chunk separately measured 14x the inference load, over 250% of
* one core, and a chunk that short carries under two dot-lengths of context anyway.
*/
private suspend fun decodePending(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
pendingText = runInference(activeSession, activeEnvironment, spectrogram)
_historyText.value = committedText + pendingText
} }
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */ /** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
@@ -550,6 +587,8 @@ class CwDeepDecoder(
buffer.reset() buffer.reset()
_decodedText.value = "" _decodedText.value = ""
_historyText.value = "" _historyText.value = ""
committedText = ""
pendingText = ""
archiveSize = 0 archiveSize = 0
_estimatedPitch.value = null _estimatedPitch.value = null
_detectedToneHz.value = null _detectedToneHz.value = null
@@ -0,0 +1,77 @@
/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name.
*
* Why the catalogue number and not the name: the same satellite carries different names in
* different TLE sources, so a name-keyed table misses whenever the user switches source.
* Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in
* both are named differently - NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the
* other, 43700 is "ES'HAIL 2" against "QO-100". The catalogue number is identical in every
* source, so it is the only stable key.
*
* LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list
* (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7
* instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling
* held in LotwSatellites rather than to whatever the TLE happens to say.
*
* Every number here was read out of live TLE data, never typed from memory. Entries cover the
* satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in
* the LoTW list; the rest of that list is satellites no source still carries, so no user can
* track them and no mapping is needed for them.
*/
package com.rtbishop.look4sat.core.domain.wavelog
object LotwSatelliteIds {
/**
* NORAD catalogue number to the LoTW spelling. The trailing comment is one name the
* satellite goes by in the sources, kept so a reader can recognise the entry.
*
* Three numbers had to be decided rather than derived, because one name matched several
* catalogued objects. Each was settled by which object the amateur-specific sources carry:
* - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY),
* (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work.
* - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls
* it ROBUSTA 1F, which is a different satellite.
* - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears
* only in the general catalogue.
*/
private val idToName: Map<Int, String> = mapOf(
7530 to "AO-7", // AO-07
14129 to "AO-10", // PHASE 3B (AO-10)
20439 to "AO-16", // OSCAR 16 (PACSAT)
20442 to "LO-19", // LO-19
22825 to "AO-27", // AO-27
23439 to "RS-15", // RADIO ROSTO (RS-15)
24278 to "FO-29", // FO-29
25544 to "ARISS", // ISS (ZARYA)
26609 to "AO-40", // PHASE 3D (AO-40)
26931 to "NO-44", // NO-44
27607 to "SO-50", // SAUDISAT 1C (SO-50)
28650 to "VO-52", // HAMSAT (VO-52)
39444 to "AO-73", // AO-73
40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1
40074 to "UKUBE1", // UKUBE-1
40908 to "CAS-3H", // LILACSAT-2
40931 to "IO-86", // IO-86
40967 to "AO-85", // FOX-1A (AO-85)
41847 to "CAS-2T", // CAS-2T
43017 to "AO-91", // AO-91
43678 to "PO-101", // DIWATA-2B
43700 to "QO-100", // ES'HAIL 2
43803 to "JO-97", // JO-97
44530 to "TAURUS", // TAURUS-1
44881 to "TO-108", // CAS-6 (TO-108)
44909 to "RS-44", // DOSAAF-85 (RS-44)
50466 to "HO-113", // CAMSAT XW-3 (CAS-9)
53109 to "IO-117", // GREENCUBE (IO-117)
61781 to "AO-123" // AO-123
)
/** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */
fun nameFor(catnum: Int): String? = idToName[catnum]
/** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */
fun isKnown(catnum: Int): Boolean = catnum in idToName
/** Entry count, so a test can catch the table being emptied by a bad edit. */
val size: Int get() = idToName.size
}
@@ -0,0 +1,94 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LotwSatelliteIdsTest {
/**
* Every name the table produces has to exist in the LoTW list verbatim, because LoTW
* rejects a QSO whose SAT_NAME is spelled differently - AO7 for AO-7 is refused.
*/
@Test
fun `every mapped name is spelled exactly as LoTW has it`() {
val known = LotwSatellites.names
for (catnum in catnums) {
val name = LotwSatelliteIds.nameFor(catnum)
assertTrue("$catnum maps to $name, which LoTW does not list", name in known)
}
}
/**
* The catalogue number is the key precisely so that the source a user happens to fetch
* from cannot change the answer. These are the numbers whose names differ most between
* Celestrak and AMSAT, so they are the ones worth pinning.
*/
@Test
fun `names that differ between sources resolve to the LoTW spelling`() {
assertEquals("AO-91", LotwSatelliteIds.nameFor(43017)) // RADFXSAT (FOX-1B) / AO-91
assertEquals("QO-100", LotwSatelliteIds.nameFor(43700)) // ES'HAIL 2 / QO-100
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) // TIANYAN 01 / CAS-6 (TO-108)
assertEquals("SO-50", LotwSatelliteIds.nameFor(27607)) // SAUDISAT 1C (SO-50) / SO-50
assertEquals("AO-123", LotwSatelliteIds.nameFor(61781)) // ASRTU-1 (AO-123) / AO-123
}
/**
* ARISS is the station, not its modules. Celestrak's full catalogue lists ISS (UNITY),
* (ZVEZDA), (DESTINY) and (NAUKA) as separate objects; matching on the name "ISS" pulled
* all of them in, and a QSO cannot be worked through a module.
*/
@Test
fun `ARISS is the station and not one of its modules`() {
assertEquals("ARISS", LotwSatelliteIds.nameFor(25544))
for (module in listOf(25575, 26400, 26700, 49044)) {
assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module))
}
}
/**
* One source (R4UAB) names 53109 as ROBUSTA 1F while four others call it GREENCUBE
* (IO-117), and 53106 appears in that one source alone. Trusting either would have put a
* second catalogue number on the same LoTW name.
*/
@Test
fun `IO-117 resolves to the number the amateur sources agree on`() {
assertEquals("IO-117", LotwSatelliteIds.nameFor(53109))
assertNull(LotwSatelliteIds.nameFor(53106))
}
/** 44879 is TIANQIN 1, catalogued but not an amateur satellite carrying TO-108. */
@Test
fun `TO-108 does not also match TIANQIN 1`() {
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881))
assertNull(LotwSatelliteIds.nameFor(44879))
}
/** No two numbers may share a name, or one of them is the wrong satellite. */
@Test
fun `each LoTW name is claimed by a single catalogue number`() {
val names = catnums.mapNotNull { LotwSatelliteIds.nameFor(it) }
assertEquals(names.size, names.toSet().size)
}
@Test
fun `isKnown agrees with nameFor`() {
assertTrue(LotwSatelliteIds.isKnown(27607))
assertFalse(LotwSatelliteIds.isKnown(99999))
assertNull(LotwSatelliteIds.nameFor(99999))
}
/** Guards against an edit that empties or truncates the table. */
@Test
fun `table holds every entry`() {
assertEquals(catnums.size, LotwSatelliteIds.size)
}
private val catnums = listOf(
7530, 14129, 20439, 20442, 22825, 23439, 24278, 25544, 26609, 26931,
27607, 28650, 39444, 40025, 40074, 40908, 40931, 40967, 41847, 43017,
43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781
)
}
@@ -93,6 +93,7 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string> \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_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat * BARU: 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: 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: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
@@ -92,6 +92,7 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string> \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_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat * BARU: 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: 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: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
@@ -97,6 +97,7 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string> \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_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* DÜZELTME: CW metni artık kod çözme beklerken yazıyı siliyormuş gibi görünmüyor
* YENİ: CW şelalesi tüm ses bandını gösterir, çözücünün ulaşamadığı ton da görünür * YENİ: CW ş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İ: 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İ: ekran okuyucular artık CW şelalesini ve AMSAT gün hücrelerini okuyor
@@ -89,6 +89,7 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string> <string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string> <string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* 修复: CW 文本框不再出现文字消失一段又补回来的现象
* 新: CW 瀑布图显示完整音频频段, 解码范围外的音调也看得见 * 新: CW 瀑布图显示完整音频频段, 解码范围外的音调也看得见
* 新: CW 文本框自动跟随新内容, 往上翻阅时自动停止跟随 * 新: CW 文本框自动跟随新内容, 往上翻阅时自动停止跟随
* 新: 读屏软件现在可以播报 CW 瀑布图与 AMSAT 日格内容 * 新: 读屏软件现在可以播报 CW 瀑布图与 AMSAT 日格内容
@@ -101,6 +101,7 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \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_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* FIX: the CW transcript no longer appears to delete text while audio waits to be decoded
* NEW: the CW waterfall shows the whole audio band, so a tone the decoder cannot reach is still visible * NEW: the CW 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: 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: screen readers now describe the CW waterfall and the AMSAT day cells
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "465" appVersionCode = "467"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.5.8" appVersionName = "4.6.0"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry