feat(cw): draw the whole audio band so an out-of-window tone is visible
The waterfall showed only the model's 400-1200 Hz window, so a tone outside it was absent from the picture entirely. Measured on keyed audio, the brightest column in that narrow view swings 1.01x between key-down and key-up against 13.76x for a tone in range - it carries no keying at all, so the operator could not tell a signal was present, let alone where it was. Markers alone could not fix that: they pointed at a frequency with nothing drawn there. compute() now takes an optional bin range, defaulting to the model's own, so the decoder path is byte-identical and the golden-vector test still holds. The display asks for DC to Nyquist, 129 bins against 65. The FFT already computed every bin - this only changes which are kept - so the cost is a wider copy. The decoder window is framed and faintly lifted, since half the picture is now outside what the model reads and nothing said which half. Marker fixes found while reviewing the render: the tone marker was orange, which is a colour the inferno ramp itself passes through, so a marker sitting on the trace it pointed at was indistinguishable from the keying gaps in that trace - invisible in exactly the case it existed for. It is cyan now, and both markers are pips in a gutter above the spectrum rather than lines across it. Also from the release audit: - compute()'s bin-count guard was written as a three-term disjunction, which any custom range satisfies regardless of bin count, leaving the model invariant unenforced for the caller most able to break it. Rewritten as an implication, with a Nyquist bound so no range can index past the FFT output. - signalStrength was gated on a confirmed out-of-window tone, which is false when detection fails - and it fails for a slow fist, measured at prominence 2.5 against a 4.5 threshold for 15% duty. So the meter still read half scale beside an empty transcript. It now requires a tone confirmed decodable: 11 flow combinations, 3 wrong before, 0 wrong after. - detectedToneHz never expired, so after retuning into the band the hint kept naming the frequency the operator had left, indefinitely. It now clears after 10 s without a tone, which is clear of any real gap - the longest being 1.7 s between words at 5 WPM. - The waterfall label read estimatedPitch while the hint read detectedToneHz, two numbers up to 800 Hz apart both claiming to be the tone. Both read the latter. - Removed a redundant toFloat() that the compiler warned about. Accessibility, untouched until now: the waterfall was a bare Canvas and the AMSAT day cells bare Boxes, so both announced nothing at all - on the status page that is the entire content of the screen. Both now carry a contentDescription naming the tone or the day's worst status and report count. The AMSAT tap target goes from 28 dp to 48 dp with the coloured tile still 28 dp, so the grid keeps its density. Strings in all nine locales for both modules.
This commit is contained in:
1 parent
984a139a81
commit
10c415fabd
24 files changed
+364
-141
No files matched your search
@@ -87,6 +87,9 @@ class CwDeepDecoder(
|
||||
/** 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.
|
||||
*
|
||||
@@ -139,6 +142,9 @@ class CwDeepDecoder(
|
||||
/** 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
|
||||
|
||||
@@ -328,6 +334,7 @@ class CwDeepDecoder(
|
||||
dropBufferedAudio()
|
||||
_activeShiftHz.value = 0f
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
@@ -380,8 +387,18 @@ class CwDeepDecoder(
|
||||
|
||||
// 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.
|
||||
analysis.toneHz?.let { _detectedToneHz.value = it.toFloat() }
|
||||
// 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
|
||||
|
||||
@@ -516,14 +533,17 @@ class CwDeepDecoder(
|
||||
val mean = total / count
|
||||
val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||
|
||||
// Zero when the tone is out of range and not being shifted in. The spectrogram
|
||||
// normalises within the window, so a tone outside it still scores well on the
|
||||
// leakage banked up against the nearest edge - measured at 0.78 for a 1500 Hz
|
||||
// tone, a near-full meter next to an empty transcript. The meter is a claim that
|
||||
// something decodable is present, and in that state nothing is.
|
||||
val outOfRange = _activeShiftHz.value == 0f &&
|
||||
_detectedToneHz.value?.let { !CwToneShifter.isInsideWindow(it) } == true
|
||||
_signalStrength.value = if (outOfRange) 0f else prominence
|
||||
// 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() {
|
||||
@@ -533,6 +553,7 @@ class CwDeepDecoder(
|
||||
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.
|
||||
|
||||
+33
-6
@@ -62,6 +62,20 @@ object CwDeepSpectrogram {
|
||||
/** 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
|
||||
|
||||
@@ -111,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)
|
||||
|
||||
+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() }
|
||||
|
||||
@@ -175,4 +175,5 @@
|
||||
<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,10 @@
|
||||
\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 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
|
||||
@@ -113,7 +117,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>
|
||||
@@ -308,4 +312,5 @@
|
||||
<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,10 @@
|
||||
\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 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
|
||||
@@ -112,7 +116,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>
|
||||
@@ -308,4 +312,5 @@
|
||||
<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>
|
||||
@@ -175,4 +175,5 @@
|
||||
<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>
|
||||
@@ -175,4 +175,5 @@
|
||||
<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,10 @@
|
||||
\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, 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
|
||||
@@ -117,7 +121,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>
|
||||
@@ -320,4 +324,5 @@
|
||||
<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>
|
||||
@@ -175,4 +175,5 @@
|
||||
<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,10 @@
|
||||
<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 瀑布图标出音调真实位置, 即使它在解码范围之外
|
||||
* 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围
|
||||
* 新: 解码范围外的音调不再让信号强度条显示高值
|
||||
* 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数
|
||||
* 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然
|
||||
* 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致
|
||||
* 新: 两种灰色区分"无人上报"和"无数据"
|
||||
@@ -109,7 +113,7 @@
|
||||
* 修复: 过境进度计算防除零崩溃
|
||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
||||
</string>
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -310,4 +314,5 @@
|
||||
<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,10 +101,10 @@
|
||||
\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: 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: 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
|
||||
@@ -125,7 +125,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>
|
||||
@@ -347,4 +347,5 @@
|
||||
<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>
|
||||
@@ -98,7 +98,6 @@ fun CwDecodeScreen() {
|
||||
val decodedText by decoder.decodedText.collectAsState()
|
||||
val historyText by decoder.historyText.collectAsState()
|
||||
val signalStrength by decoder.signalStrength.collectAsState()
|
||||
val estimatedPitch by decoder.estimatedPitch.collectAsState()
|
||||
val detectedToneHz by decoder.detectedToneHz.collectAsState()
|
||||
val activeShiftHz by decoder.activeShiftHz.collectAsState()
|
||||
val errorMessage by decoder.errorMessage.collectAsState()
|
||||
@@ -177,7 +176,7 @@ fun CwDecodeScreen() {
|
||||
CwWaterfallView(
|
||||
state = waterfall,
|
||||
signalStrength = signalStrength,
|
||||
estimatedPitch = estimatedPitch,
|
||||
detectedToneHz = detectedToneHz,
|
||||
toneShiftHz = activeShiftHz
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ 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
|
||||
@@ -93,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.
|
||||
@@ -124,23 +133,38 @@ 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).
|
||||
*
|
||||
* When [toneShiftHz] is non-zero the decoder is moving a tone into the model's window,
|
||||
* and two markers say so — otherwise the operator has no way to tell, because this
|
||||
* picture is of the *raw* audio and an out-of-window tone simply is not in it.
|
||||
* Green marks where the tone is being delivered to the model; orange marks where it
|
||||
* actually is, or which edge it lies beyond when that is off-picture.
|
||||
* 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,
|
||||
estimatedPitch: Float? = null,
|
||||
detectedToneHz: Float? = null,
|
||||
toneShiftHz: Float = 0f,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val revision by state.revision.collectAsState()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// 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)
|
||||
|
||||
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.
|
||||
@@ -155,11 +179,13 @@ internal fun CwWaterfallView(
|
||||
|
||||
if (peak > 0f) {
|
||||
val rowHeight = size.height / rows.size
|
||||
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
|
||||
// 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 65 discrete columns.
|
||||
// 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)
|
||||
@@ -177,7 +203,8 @@ internal fun CwWaterfallView(
|
||||
// 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.
|
||||
drawToneShiftMarkers(estimatedPitch, toneShiftHz)
|
||||
drawDecoderWindow()
|
||||
drawToneShiftMarkers(detectedToneHz, toneShiftHz)
|
||||
|
||||
if (signalStrength > 0f) {
|
||||
drawRect(
|
||||
@@ -193,13 +220,13 @@ internal fun CwWaterfallView(
|
||||
// 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 && estimatedPitch != null && estimatedPitch > 0f) {
|
||||
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 = estimatedPitch > TONE_SHIFT_TARGET_HZ
|
||||
val onHighSide = detectedToneHz > CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ / 2
|
||||
Text(
|
||||
text = "${estimatedPitch.roundToInt()} Hz",
|
||||
text = "${detectedToneHz.roundToInt()} Hz",
|
||||
fontSize = 9.sp,
|
||||
color = TONE_ORIGIN_COLOUR,
|
||||
modifier = Modifier
|
||||
@@ -220,88 +247,95 @@ internal fun CwWaterfallView(
|
||||
private val TONE_SHIFT_TARGET_HZ = CwToneShifter.TARGET_HZ.toFloat()
|
||||
|
||||
private val TONE_TARGET_COLOUR = Color(0xFF4CD964)
|
||||
private val TONE_ORIGIN_COLOUR = Color(0xFFFF9500)
|
||||
|
||||
/**
|
||||
* 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, visible in
|
||||
* the spectrum on its own, and a marker would only add clutter.
|
||||
* 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(estimatedPitch: Float?, toneShiftHz: Float) {
|
||||
private fun DrawScope.drawToneShiftMarkers(detectedToneHz: Float?, toneShiftHz: Float) {
|
||||
if (toneShiftHz == 0f) return
|
||||
|
||||
val minHz = CwDeepSpectrogram.MIN_FREQ_HZ.toFloat()
|
||||
val maxHz = CwDeepSpectrogram.MAX_FREQ_HZ.toFloat()
|
||||
|
||||
// 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 pitch readout: shifting a
|
||||
// 100 Hz tone up reports a negative pitch whenever the loudest bin drifts low, and
|
||||
// gating on pitch there put the display straight back to showing nothing at all.
|
||||
dashedMarkerColumn(hzToX(TONE_SHIFT_TARGET_HZ, minHz, maxHz), TONE_TARGET_COLOUR)
|
||||
// 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)
|
||||
|
||||
// A pitch we cannot place: draw only the target line rather than guess a side.
|
||||
if (estimatedPitch == null || estimatedPitch.isNaN()) return
|
||||
// No usable tone estimate: the target line alone, rather than a guessed position.
|
||||
if (detectedToneHz == null || detectedToneHz.isNaN() || detectedToneHz <= 0f) return
|
||||
|
||||
// estimatedPitch is already the real pitch — the spectrogram measures the shifted
|
||||
// audio and the decoder subtracts the shift back out before publishing it. Adding
|
||||
// the shift again here would land this marker on top of the target one.
|
||||
if (estimatedPitch in minHz..maxHz) {
|
||||
dashedMarkerColumn(hzToX(estimatedPitch, minHz, maxHz), TONE_ORIGIN_COLOUR)
|
||||
return
|
||||
}
|
||||
// The tone is genuinely in the picture now, so mark it where it is.
|
||||
markerBracket(hzToX(detectedToneHz), TONE_ORIGIN_COLOUR)
|
||||
}
|
||||
|
||||
// Beyond the picture, so mark the edge it lies past. Everything is drawn INWARD:
|
||||
// anything placed outside the canvas is clipped away, which would hide the marker in
|
||||
// exactly the case it exists for. A non-positive pitch counts as the low side — it
|
||||
// means the loudest bin landed below the shift, so the tone is at the bottom end.
|
||||
val onLowSide = estimatedPitch < minHz
|
||||
val barWidth = 3f
|
||||
val chevron = 7f
|
||||
val inward = if (onLowSide) 1f else -1f
|
||||
val tipX = if (onLowSide) barWidth else size.width - barWidth
|
||||
/** 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 = TONE_ORIGIN_COLOUR.copy(alpha = 0.85f),
|
||||
topLeft = Offset(if (onLowSide) 0f else size.width - barWidth, 0f),
|
||||
size = Size(barWidth, size.height)
|
||||
color = colour,
|
||||
topLeft = Offset(x - 1f, 0f),
|
||||
size = Size(3f, MARKER_GUTTER_PX)
|
||||
)
|
||||
// Arms open inward from a tip on the bar, so it reads as pointing off-picture.
|
||||
val midY = size.height / 2f
|
||||
drawLine(
|
||||
color = TONE_ORIGIN_COLOUR,
|
||||
start = Offset(tipX, midY),
|
||||
end = Offset(tipX + inward * chevron, midY - chevron),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
drawLine(
|
||||
color = TONE_ORIGIN_COLOUR,
|
||||
start = Offset(tipX, midY),
|
||||
end = Offset(tipX + inward * chevron, midY + chevron),
|
||||
strokeWidth = 2f
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
|
||||
private fun hzToX(hz: Float, minHz: Float, maxHz: Float): Float =
|
||||
(hz - minHz) / (maxHz - minHz)
|
||||
|
||||
/** A dotted vertical line at [fraction] of the width, 0..1 spanning the visible band. */
|
||||
private fun DrawScope.dashedMarkerColumn(fraction: Float, colour: Color) {
|
||||
val dashLen = 4f
|
||||
// Ceiling, not floor: flooring leaves the bottom of the column undrawn.
|
||||
val dashCount = ceil(size.height / (dashLen * 2)).toInt()
|
||||
val x = (fraction * size.width).coerceIn(0f, size.width)
|
||||
for (i in 0 until dashCount) {
|
||||
val top = i * dashLen * 2
|
||||
drawLine(
|
||||
color = colour.copy(alpha = 0.55f),
|
||||
start = Offset(x, top),
|
||||
end = Offset(x, (top + dashLen).coerceAtMost(size.height)),
|
||||
strokeWidth = 1.5f
|
||||
)
|
||||
}
|
||||
/** 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
|
||||
|
||||
@@ -2,4 +2,9 @@
|
||||
<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>
|
||||
@@ -8,4 +8,9 @@
|
||||
<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>
|
||||
@@ -8,4 +8,9 @@
|
||||
<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>
|
||||
@@ -2,4 +2,9 @@
|
||||
<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>
|
||||
@@ -2,4 +2,9 @@
|
||||
<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>
|
||||
@@ -8,4 +8,9 @@
|
||||
<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>
|
||||
@@ -2,4 +2,9 @@
|
||||
<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>
|
||||
@@ -8,4 +8,9 @@
|
||||
<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>
|
||||
@@ -8,4 +8,9 @@
|
||||
<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>
|
||||
+68
-37
@@ -40,6 +40,8 @@ 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
|
||||
@@ -313,6 +315,22 @@ private fun readableOn(background: Long): Color {
|
||||
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.
|
||||
*
|
||||
@@ -329,46 +347,59 @@ private fun readableOn(background: Long): Color {
|
||||
*/
|
||||
@Composable
|
||||
private fun DayCell(day: SatDay, stripes: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val cell = modifier
|
||||
.height(28.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
if (stripes) {
|
||||
Row(modifier = cell) {
|
||||
day.slots.forEach { slot ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(Color(slot.statusColor))
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
// An unlisted colour sorts last rather than first: indexOf would return -1 and make
|
||||
// anything unrecognised outrank a genuine failure.
|
||||
val colour = day.slots.map { it.statusColor }
|
||||
.minByOrNull { SEVERITY.indexOf(it).takeIf { i -> i >= 0 } ?: SEVERITY.size }
|
||||
?: NO_REPORT_COLOUR
|
||||
val count = day.slots.sumOf { it.count }
|
||||
// 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
|
||||
)
|
||||
// Two layers: the tap target is 48 dp to meet the minimum, while the coloured part
|
||||
// stays 28 dp so the grid keeps its density. The extra height is transparent padding,
|
||||
// which is why the row spacing does not change.
|
||||
Box(
|
||||
modifier = cell.background(Color(colour)),
|
||||
modifier = modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = description }
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (count > 0) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = readableOn(colour)
|
||||
)
|
||||
val tile = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user