diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt index b4b6cbce..2a0fd270 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt @@ -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. diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt index 4d308566..209b2ead 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt @@ -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 { + fun compute( + audio: FloatArray, + minHz: Double = MIN_FREQ_HZ, + maxHz: Double = MAX_FREQ_HZ + ): Array { 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) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt index d88ffa51..e5519b53 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt @@ -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() } diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index dd4d6151..70015969 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -175,4 +175,5 @@ Sin datos AMSAT: una franja por franja de 2 horas 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. + %1$s, %2$s, %3$d informes diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml index e798853b..93fc73b0 100644 --- a/core/presentation/src/main/res/values-id/strings.xml +++ b/core/presentation/src/main/res/values-id/strings.xml @@ -93,6 +93,10 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat + * 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 - + Kembali Beri tahu Azimuth @@ -308,4 +312,5 @@ Tidak ada data AMSAT: satu garis per slot 2 jam 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. + %1$s, %2$s, %3$d laporan diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index ede6eed9..fc507b03 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -92,6 +92,10 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat + * 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 - + Kembali Beri tahu Azimuth @@ -308,4 +312,5 @@ Tidak ada data AMSAT: satu garis per slot 2 jam 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. + %1$s, %2$s, %3$d laporan diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 8a5da46f..6e72f297 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -175,4 +175,5 @@ Нет данных AMSAT: полоса на каждые 2 часа Включено — каждый день это двенадцать двухчасовых полос, поэтому перерыв внутри дня виден. Выключено — каждый день это один цвет и число отчётов; цвет соответствует худшему состоянию за день, так что даже один сбой остаётся заметен. + %1$s, %2$s, отчётов: %3$d diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index a33467c2..af165e60 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -175,4 +175,5 @@ දත්ත නැත AMSAT: පැය 2 කට එක තීරුවක් සක්‍රිය විට සෑම දිනයක් පැය දෙකේ තීරු දොළහකි, එබැවින් දිනක් තුළ ඇති බිඳවැටීම දැකිය හැක. අක්‍රිය විට සෑම දිනයක් එක් වර්ණයක් සහ වාර්තා ගණනකි — වර්ණය එදින නරකම තත්ත්වයයි, එබැවින් එක් අසාර්ථකත්වයක් වුවද පෙනේ. + %1$s, %2$s, වාර්තා %3$d diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 5e432a48..87b0540d 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -97,6 +97,10 @@ \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin. What\'s new in Look4Sat + * 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ı - + Geri @@ -320,4 +324,5 @@ Veri yok AMSAT: 2 saatlik dilim başına şerit 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. + %1$s, %2$s, %3$d rapor diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 56dcd62d..ae25b990 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -175,4 +175,5 @@ Немає даних AMSAT: смуга на кожні 2 години Увімкнено — кожен день це дванадцять двогодинних смуг, тож перерва всередині дня видна. Вимкнено — кожен день це один колір і кількість звітів; колір відповідає найгіршому стану за день, тож навіть один збій залишається помітним. + %1$s, %2$s, звітів: %3$d diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 4f32c7aa..7263935c 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -89,6 +89,10 @@ 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性 Look4Sat Pro 更新内容 + * 新: CW 瀑布图标出音调真实位置, 即使它在解码范围之外 + * 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围 + * 新: 解码范围外的音调不再让信号强度条显示高值 + * 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数 * 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然 * 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致 * 新: 两种灰色区分"无人上报"和"无数据" @@ -109,7 +113,7 @@ * 修复: 过境进度计算防除零崩溃 * 修复: 并发 calculatePasses 调用不再触发重复计算 * 修复: WaveLog 重复提交 QSO 与网格更新竞态 - + 后退 @@ -310,4 +314,5 @@ 无数据 AMSAT:每 2 小时一条纹 开启时每天画成 12 条两小时条纹,一天之内的中断也看得见。关闭时每天显示一个颜色和报告总数——颜色取当天最差状态,所以出现过一次故障也不会被藏起来。 + %1$s,%2$s,%3$d 条报告 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 319e7812..721a55ca 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -101,10 +101,10 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * 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 - + Back @@ -347,4 +347,5 @@ No data AMSAT: stripe per 2-hour slot 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. + %1$s, %2$s, %3$d reports diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt index bd4803af..9334f878 100644 --- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt @@ -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 ) } diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt index 42ddd435..ac0e02c6 100644 --- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt @@ -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 diff --git a/feature/cw/src/main/res/values-es/app_values.xml b/feature/cw/src/main/res/values-es/app_values.xml index bfb84cdf..5f2644c7 100644 --- a/feature/cw/src/main/res/values-es/app_values.xml +++ b/feature/cw/src/main/res/values-es/app_values.xml @@ -2,4 +2,9 @@ Tono de %1$d Hz trasladado a la ventana de decodificación de 400-1200 Hz 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. + Espectro de cascada, %1$s + aún sin señal + tono de %1$d hercios, trasladado al rango de decodificación + tono de %1$d hercios, fuera del rango de decodificación + tono de %1$d hercios diff --git a/feature/cw/src/main/res/values-id/app_values.xml b/feature/cw/src/main/res/values-id/app_values.xml index d8911601..3569a21a 100644 --- a/feature/cw/src/main/res/values-id/app_values.xml +++ b/feature/cw/src/main/res/values-id/app_values.xml @@ -8,4 +8,9 @@ Buka pengaturan aplikasi Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan. + Spektrum air terjun, %1$s + belum ada sinyal + nada %1$d hertz, dipindahkan ke rentang dekode + nada %1$d hertz, di luar rentang dekode + nada %1$d hertz diff --git a/feature/cw/src/main/res/values-in/app_values.xml b/feature/cw/src/main/res/values-in/app_values.xml index d8911601..3569a21a 100644 --- a/feature/cw/src/main/res/values-in/app_values.xml +++ b/feature/cw/src/main/res/values-in/app_values.xml @@ -8,4 +8,9 @@ Buka pengaturan aplikasi Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan. + Spektrum air terjun, %1$s + belum ada sinyal + nada %1$d hertz, dipindahkan ke rentang dekode + nada %1$d hertz, di luar rentang dekode + nada %1$d hertz diff --git a/feature/cw/src/main/res/values-ru/app_values.xml b/feature/cw/src/main/res/values-ru/app_values.xml index e77fe3cf..8026694d 100644 --- a/feature/cw/src/main/res/values-ru/app_values.xml +++ b/feature/cw/src/main/res/values-ru/app_values.xml @@ -2,4 +2,9 @@ Тон %1$d Гц перенесён в окно декодирования 400-1200 Гц Тон %1$d Гц находится вне окна декодирования 400-1200 Гц. Включите сдвиг тона в настройках. + Водопадный спектр, %1$s + сигнала пока нет + тон %1$d герц, перенесён в диапазон декодирования + тон %1$d герц, вне диапазона декодирования + тон %1$d герц diff --git a/feature/cw/src/main/res/values-si/app_values.xml b/feature/cw/src/main/res/values-si/app_values.xml index 2c1719a9..0a4a4541 100644 --- a/feature/cw/src/main/res/values-si/app_values.xml +++ b/feature/cw/src/main/res/values-si/app_values.xml @@ -2,4 +2,9 @@ %1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවට ගෙන ගියා %1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවෙන් පිටත. සැකසුම් තුළ ස්වර මාරුව සක්‍රිය කරන්න. + දිය ඇලි වර්ණාවලිය, %1$s + තවම සංඥාවක් නැත + ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයට ගෙන ගියා + ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයෙන් පිටත + ස්වරය %1$d හර්ට්ස් diff --git a/feature/cw/src/main/res/values-tr/app_values.xml b/feature/cw/src/main/res/values-tr/app_values.xml index 839ad8eb..52fe7962 100644 --- a/feature/cw/src/main/res/values-tr/app_values.xml +++ b/feature/cw/src/main/res/values-tr/app_values.xml @@ -8,4 +8,9 @@ Uygulama ayarlarını aç %1$d Hz tonu 400-1200 Hz kod çözme aralığına taşındı %1$d Hz tonu 400-1200 Hz kod çözme aralığının dışında. Ayarlar\'dan ton kaydırmayı açın. + Şelale spektrumu, %1$s + henüz sinyal yok + ton %1$d hertz, kod çözme aralığına taşındı + ton %1$d hertz, kod çözme aralığının dışında + ton %1$d hertz diff --git a/feature/cw/src/main/res/values-uk/app_values.xml b/feature/cw/src/main/res/values-uk/app_values.xml index 3cf3671b..6887054f 100644 --- a/feature/cw/src/main/res/values-uk/app_values.xml +++ b/feature/cw/src/main/res/values-uk/app_values.xml @@ -2,4 +2,9 @@ Тон %1$d Гц перенесено у вікно декодування 400-1200 Гц Тон %1$d Гц перебуває поза вікном декодування 400-1200 Гц. Увімкніть зсув тону в налаштуваннях. + Водоспадний спектр, %1$s + сигналу ще немає + тон %1$d герц, перенесено в діапазон декодування + тон %1$d герц, поза діапазоном декодування + тон %1$d герц diff --git a/feature/cw/src/main/res/values-zh/app_values.xml b/feature/cw/src/main/res/values-zh/app_values.xml index d87b2d97..42f7493f 100644 --- a/feature/cw/src/main/res/values-zh/app_values.xml +++ b/feature/cw/src/main/res/values-zh/app_values.xml @@ -8,4 +8,9 @@ 打开应用设置 已将 %1$d Hz 音调搬入 400-1200 Hz 解码范围 %1$d Hz 音调在 400-1200 Hz 解码范围外,请在设置中开启音调搬移。 + 瀑布频谱图,%1$s + 暂无信号 + 音调 %1$d 赫兹,已搬入解码范围 + 音调 %1$d 赫兹,在解码范围外 + 音调 %1$d 赫兹 diff --git a/feature/cw/src/main/res/values/app_values.xml b/feature/cw/src/main/res/values/app_values.xml index 27640fda..a7ec9789 100644 --- a/feature/cw/src/main/res/values/app_values.xml +++ b/feature/cw/src/main/res/values/app_values.xml @@ -8,4 +8,9 @@ Open app settings Tone %1$d Hz moved into the 400-1200 Hz decoder window Tone %1$d Hz is outside the 400-1200 Hz decoder window. Enable tone shift in Settings. + Waterfall spectrum, %1$s + no signal yet + tone at %1$d hertz, moved into the decoder range + tone at %1$d hertz, outside the decoder range + tone at %1$d hertz diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 27d863a9..4113e99a 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -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) + ) + } } } }