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 953de749..b4b6cbce 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 @@ -106,6 +106,9 @@ class CwDeepDecoder( private val _estimatedPitch = MutableStateFlow(null) override val estimatedPitch: StateFlow = _estimatedPitch.asStateFlow() + private val _detectedToneHz = MutableStateFlow(null) + override val detectedToneHz: StateFlow = _detectedToneHz.asStateFlow() + private val _activeShiftHz = MutableStateFlow(0f) override val activeShiftHz: StateFlow = _activeShiftHz.asStateFlow() @@ -324,23 +327,30 @@ class CwDeepDecoder( Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio") dropBufferedAudio() _activeShiftHz.value = 0f + _detectedToneHz.value = null shiftDecider.reset() lastDetectAtMs = 0L detectionPool.clear() streamingShifter.reset() } - if (!enabled) return resampled - + // Detection runs whether or not shifting is enabled. It is the only measurement + // that can see past the model's window, so with it skipped an out-of-window tone + // left the UI with nothing truthful to show: the spectrogram's own pitch readout + // is arithmetically confined to the window and reports the leakage piled against + // the nearest edge, so a 1500 Hz tone published "1200 Hz" and a healthy signal + // level while decoding nothing at all. detectionPool.add(resampled) val now = System.currentTimeMillis() val elapsed = now - lastDetectAtMs if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) { lastDetectAtMs = now - runDetection(detectionPool.drain()) + runDetection(detectionPool.drain(), shiftEnabled = enabled) } + if (!enabled) return resampled + // Streaming keeps the Hilbert filter history and mixer phase across chunks; // shifting each chunk in isolation distorted the 62 samples at its edges. return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE) @@ -365,8 +375,16 @@ class CwDeepDecoder( * meant tests could only restate it, and a restated rule cannot fail when the real * one is wrong - four injected defects once left the whole suite green. */ - private fun runDetection(sample: FloatArray) { + private fun runDetection(sample: FloatArray, shiftEnabled: Boolean) { val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE) + + // Published either way: the UI needs the real pitch to say why nothing decodes + // when shifting is off and the tone is out of range. Held through silences for + // the same reason the shift is - CW is gaps, and a gap is not a retune. + analysis.toneHz?.let { _detectedToneHz.value = it.toFloat() } + + if (!shiftEnabled) return + val decision = shiftDecider.accept(analysis) _activeShiftHz.value = decision.shiftHz @@ -496,7 +514,16 @@ class CwDeepDecoder( _estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat() val mean = total / count - _signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f) + val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f) + + // 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 } override fun reset() { @@ -505,6 +532,7 @@ class CwDeepDecoder( _historyText.value = "" archiveSize = 0 _estimatedPitch.value = null + _detectedToneHz.value = null _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/ICwDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt index 5ad99769..0746ceb3 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt @@ -43,9 +43,25 @@ interface ICwDecoder { */ val historyText: StateFlow - /** Detected tone frequency in Hz, or null before a tone is found. */ + /** + * Pitch of the tone the model is decoding, in Hz, or null before one is found. + * + * Derived from the spectrogram, so it can only ever report a frequency inside the + * model's analysis window. For the pitch of a tone the model cannot see, use + * [detectedToneHz]. + */ val estimatedPitch: StateFlow + /** + * Pitch of the loudest tone in the raw audio, in Hz, or null when none stands out. + * + * Unlike [estimatedPitch] this is measured before any shifting and over the full + * audio bandwidth, so it can report a tone the model's window excludes — which is + * the only way to tell the operator that nothing is being decoded because their tone + * is out of range. + */ + val detectedToneHz: StateFlow + /** Current shift applied to bring the tone into the model's window, 0f when idle. */ val activeShiftHz: StateFlow diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt index f84f27fc..8bde57f8 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt @@ -94,6 +94,26 @@ class CwToneShifterTest { } } + /** + * The decoder runs this scan even with shifting switched off, purely to tell the + * operator why nothing is decoding. That only works if the scan reaches past the + * model's window: the spectrogram's own pitch readout cannot, being confined to the + * window by construction, and it reports edge leakage as though it were the tone. + */ + @Test + fun `the scan reports tones the model window excludes`() { + for (tone in listOf(120.0, 250.0, 1400.0, 1500.0)) { + val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate) + val reported = analysis.toneHz + assertNotNull("$tone Hz went undetected, so the UI has nothing to report", reported) + assertEquals("$tone Hz was misreported", tone, reported!!.toDouble(), 30.0) + assertFalse( + "$tone Hz must read as outside the window", + CwToneShifter.isInsideWindow(reported) + ) + } + } + /** * The waterfall draws a marker at [CwToneShifter.TARGET_HZ] to show the operator where * a shifted tone is being delivered. Moving the target outside the model's window, or 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 b72c57b1..bd4803af 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 @@ -63,7 +63,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +import com.rtbishop.look4sat.core.domain.cw.CwToneShifter import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import kotlin.math.roundToInt import com.rtbishop.look4sat.core.presentation.R as CoreR /** @@ -97,6 +99,7 @@ fun CwDecodeScreen() { 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() @@ -179,6 +182,28 @@ fun CwDecodeScreen() { ) } + // What the markers cannot say on their own. The waterfall covers only the model's + // 400-1200 Hz window, so a tone outside it is missing from the picture entirely - + // and with tone shift off there is nothing to mark either. One line of text is + // what turns "nothing is happening" into a reason and a remedy. + val toneHz = detectedToneHz + val hint = when { + toneHz == null -> null + activeShiftHz != 0f -> stringResource(R.string.cw_tone_shifted_hint, toneHz.roundToInt()) + CwToneShifter.isInsideWindow(toneHz) -> null + else -> stringResource(R.string.cw_tone_outside_hint, toneHz.roundToInt()) + } + if (hint != null) { + Text( + text = hint, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, top = 4.dp, end = 12.dp) + ) + } + Text( text = decodedText.takeLast(64).ifEmpty { "…" }, fontSize = 20.sp, diff --git a/feature/cw/src/main/res/values-es/app_values.xml b/feature/cw/src/main/res/values-es/app_values.xml new file mode 100644 index 00000000..bfb84cdf --- /dev/null +++ b/feature/cw/src/main/res/values-es/app_values.xml @@ -0,0 +1,5 @@ + + + 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. + 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 6c1d91e8..d8911601 100644 --- a/feature/cw/src/main/res/values-id/app_values.xml +++ b/feature/cw/src/main/res/values-id/app_values.xml @@ -6,4 +6,6 @@ Izin mikrofon diperlukan untuk pendekodean CW Berikan izin 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. 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 6c1d91e8..d8911601 100644 --- a/feature/cw/src/main/res/values-in/app_values.xml +++ b/feature/cw/src/main/res/values-in/app_values.xml @@ -6,4 +6,6 @@ Izin mikrofon diperlukan untuk pendekodean CW Berikan izin 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. diff --git a/feature/cw/src/main/res/values-ru/app_values.xml b/feature/cw/src/main/res/values-ru/app_values.xml new file mode 100644 index 00000000..e77fe3cf --- /dev/null +++ b/feature/cw/src/main/res/values-ru/app_values.xml @@ -0,0 +1,5 @@ + + + Тон %1$d Гц перенесён в окно декодирования 400-1200 Гц + Тон %1$d Гц находится вне окна декодирования 400-1200 Гц. Включите сдвиг тона в настройках. + diff --git a/feature/cw/src/main/res/values-si/app_values.xml b/feature/cw/src/main/res/values-si/app_values.xml new file mode 100644 index 00000000..2c1719a9 --- /dev/null +++ b/feature/cw/src/main/res/values-si/app_values.xml @@ -0,0 +1,5 @@ + + + %1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවට ගෙන ගියා + %1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවෙන් පිටත. සැකසුම් තුළ ස්වර මාරුව සක්‍රිය කරන්න. + 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 78782bd5..839ad8eb 100644 --- a/feature/cw/src/main/res/values-tr/app_values.xml +++ b/feature/cw/src/main/res/values-tr/app_values.xml @@ -6,4 +6,6 @@ CW çözümü için mikrofon izni gerekli İzin ver 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. diff --git a/feature/cw/src/main/res/values-uk/app_values.xml b/feature/cw/src/main/res/values-uk/app_values.xml new file mode 100644 index 00000000..3cf3671b --- /dev/null +++ b/feature/cw/src/main/res/values-uk/app_values.xml @@ -0,0 +1,5 @@ + + + Тон %1$d Гц перенесено у вікно декодування 400-1200 Гц + Тон %1$d Гц перебуває поза вікном декодування 400-1200 Гц. Увімкніть зсув тону в налаштуваннях. + 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 636a2008..d87b2d97 100644 --- a/feature/cw/src/main/res/values-zh/app_values.xml +++ b/feature/cw/src/main/res/values-zh/app_values.xml @@ -6,4 +6,6 @@ CW 解码需要麦克风权限 授予权限 打开应用设置 + 已将 %1$d Hz 音调搬入 400-1200 Hz 解码范围 + %1$d Hz 音调在 400-1200 Hz 解码范围外,请在设置中开启音调搬移。 diff --git a/feature/cw/src/main/res/values/app_values.xml b/feature/cw/src/main/res/values/app_values.xml index 5ce264a5..27640fda 100644 --- a/feature/cw/src/main/res/values/app_values.xml +++ b/feature/cw/src/main/res/values/app_values.xml @@ -6,4 +6,6 @@ Microphone permission is required for CW decoding Grant permission 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.