From fa733289369a9f0426f8bbad2d2eab2413908e89 Mon Sep 17 00:00:00 2001 From: QIU Date: Sat, 22 Aug 2026 15:32:33 +0000 Subject: [PATCH] feat(cw): show tone-shift markers on the waterfall spectrogram When the tone-shift feature moves a tone into the model's 400-1200 Hz window, the waterfall now shows two visual markers so the operator can see what is happening: a green dashed line at the target (800 Hz) and an orange frequency label at the top-left showing the original pitch. The waterfall draws the RAW audio, not the shifted audio, so a 1500 Hz tone was always invisible regardless of the shift setting. The markers close the gap: the operator can now see that a tone was detected and where it was moved, even when the original pitch is outside the visible band. activeShiftHz is now a StateFlow exposed through ICwDecoder so the UI can observe it without polling. --- .../look4sat/core/data/cw/CwDeepDecoder.kt | 18 ++--- .../look4sat/core/domain/cw/ICwDecoder.kt | 3 + .../look4sat/feature/cw/CwDecodeScreen.kt | 9 ++- .../look4sat/feature/cw/CwWaterfall.kt | 80 ++++++++++++++++++- 4 files changed, 98 insertions(+), 12 deletions(-) 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 6d76ed17..953de749 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 _activeShiftHz = MutableStateFlow(0f) + override val activeShiftHz: StateFlow = _activeShiftHz.asStateFlow() + private val _signalStrength = MutableStateFlow(0f) override val signalStrength: StateFlow = _signalStrength.asStateFlow() @@ -130,9 +133,6 @@ class CwDeepDecoder( /** Held while inference runs so slow devices skip work instead of queuing it. */ private val inferenceLock = Mutex() - /** Shift currently applied to incoming audio; 0 when the tone needs no move. */ - private var activeShiftHz = 0f - /** Decides what shift to apply from successive tone estimates. */ private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ) @@ -304,7 +304,7 @@ class CwDeepDecoder( * * The detection scan is a bin-by-bin DFT, so it runs at most every * [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it - * produces is cached in [activeShiftHz] and applied to the chunks in between. A + * produces is cached in [_activeShiftHz] and applied to the chunks in between. A * tone already inside the window yields a zero shift, and then this returns the * caller's array untouched. * @@ -323,7 +323,7 @@ class CwDeepDecoder( if (enabled != previousEnabled) { Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio") dropBufferedAudio() - activeShiftHz = 0f + _activeShiftHz.value = 0f shiftDecider.reset() lastDetectAtMs = 0L detectionPool.clear() @@ -343,7 +343,7 @@ class CwDeepDecoder( // 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, CwDeepSpectrogram.SAMPLE_RATE) + return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE) } /** @@ -368,7 +368,7 @@ class CwDeepDecoder( private fun runDetection(sample: FloatArray) { val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE) val decision = shiftDecider.accept(analysis) - activeShiftHz = decision.shiftHz + _activeShiftHz.value = decision.shiftHz when (decision.outcome) { CwShiftDecider.Outcome.NO_TONE -> Log.d( @@ -493,7 +493,7 @@ class CwDeepDecoder( val absoluteBin = 32 + bestBin // Undo the shift before reporting: the spectrogram sees the moved tone, but // the readout must show the pitch the operator actually hears on the radio. - _estimatedPitch.value = (absoluteBin * binHz - activeShiftHz).toFloat() + _estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat() val mean = total / count _signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f) @@ -508,7 +508,7 @@ class CwDeepDecoder( _signalStrength.value = 0f _lastInferenceMs.value = 0 // Re-detect from scratch: the operator may have retuned before resetting. - activeShiftHz = 0f + _activeShiftHz.value = 0f shiftDecider.reset() lastDetectAtMs = 0L detectionPool.clear() 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 2672c651..5ad99769 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 @@ -46,6 +46,9 @@ interface ICwDecoder { /** Detected tone frequency in Hz, or null before a tone is found. */ val estimatedPitch: StateFlow + /** Current shift applied to bring the tone into the model's window, 0f when idle. */ + val activeShiftHz: StateFlow + /** Relative signal strength in 0..1 for level meters. */ val signalStrength: StateFlow 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 47679430..b72c57b1 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 @@ -96,6 +96,8 @@ 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 activeShiftHz by decoder.activeShiftHz.collectAsState() val errorMessage by decoder.errorMessage.collectAsState() val permissionLauncher = rememberLauncherForActivityResult( @@ -169,7 +171,12 @@ fun CwDecodeScreen() { .padding(horizontal = 8.dp) .clip(RoundedCornerShape(8.dp)) ) { - CwWaterfallView(state = waterfall, signalStrength = signalStrength) + CwWaterfallView( + state = waterfall, + signalStrength = signalStrength, + estimatedPitch = estimatedPitch, + toneShiftHz = activeShiftHz + ) } Text( 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 311cd642..5252c7cf 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 @@ -18,15 +18,21 @@ package com.rtbishop.look4sat.feature.cw import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -113,16 +119,26 @@ 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 a tone is being shifted into the model's window. + * Two markers are drawn on top of the waterfall: + * - A green dashed line at the target (800 Hz) showing where the tone lands. + * - An orange marker at the original tone frequency. When the original is outside + * the visible 400-1200 Hz band, an arrow and frequency label are drawn at the + * nearest edge pointing toward the tone. */ @Composable internal fun CwWaterfallView( state: CwWaterfallState, signalStrength: Float, + estimatedPitch: Float? = null, + toneShiftHz: Float = 0f, modifier: Modifier = Modifier ) { val revision by state.revision.collectAsState() - Canvas(modifier = modifier.fillMaxSize()) { + Box(modifier = modifier.fillMaxSize()) { + Canvas(modifier = Modifier.fillMaxSize()) { // Touch the revision inside the draw scope so a new spectrum triggers a // redraw; without this read the canvas would only ever render once. @Suppress("UNUSED_EXPRESSION") revision @@ -163,7 +179,67 @@ internal fun CwWaterfallView( size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f) ) } - } + + // Tone-shift markers: only when the shift is active and we know the pitch. + if (toneShiftHz != 0f && estimatedPitch != null && estimatedPitch > 0f) { + val minHz = CwDeepSpectrogram.MIN_FREQ_HZ.toFloat() + val maxHz = CwDeepSpectrogram.MAX_FREQ_HZ.toFloat() + val targetHz = (CwDeepSpectrogram.MIN_FREQ_HZ + (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 2.0).toFloat() + val hzToX: (Float) -> Float = { ((it - minHz) / (maxHz - minHz) * size.width).toFloat() } + val dashLen = 4f + val labelSize = 10f + + // Green dashed line at the target. + val targetX = hzToX(targetHz).coerceIn(0f, size.width) + val dashCount = (size.height / (dashLen * 2)).toInt() + for (i in 0 until dashCount) { + drawLine( + color = Color(0xFF4CD964).copy(alpha = 0.5f), + start = Offset(targetX, i * dashLen * 2), + end = Offset(targetX, (i * dashLen * 2) + dashLen), + strokeWidth = 1.5f + ) + } + + // Original pitch marker. + val origPitch = estimatedPitch + toneShiftHz // undo the correction + if (origPitch in minHz..maxHz) { + // Inside the visible band: orange dashed line at the original position. + val origX = hzToX(origPitch).coerceIn(0f, size.width) + for (i in 0 until dashCount) { + drawLine( + color = Color(0xFFFF9500).copy(alpha = 0.5f), + start = Offset(origX, i * dashLen * 2), + end = Offset(origX, (i * dashLen * 2) + dashLen), + strokeWidth = 1.5f + ) + } + } + // Draw the original frequency label at the top edge. + val labelX = hzToX(origPitch).coerceIn(labelSize, size.width - labelSize * 4) + drawRect( + color = Color(0xFFFF9500).copy(alpha = 0.15f), + topLeft = Offset(labelX - 2f, 0f), + size = Size(labelSize * 5, labelSize + 4f) + ) + // Note: Compose Canvas doesn't support drawText natively; + // the label is a composable overlaid on the waterfall instead. + } + } // end Canvas + + // Frequency labels overlaid on the waterfall. + if (toneShiftHz != 0f && estimatedPitch != null && estimatedPitch > 0f) { + val origPitch = estimatedPitch + toneShiftHz + Text( + text = "${origPitch.toInt()} Hz", + fontSize = 9.sp, + color = Color(0xFFFF9500), + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 4.dp, top = 2.dp) + ) + } + } // end Box } /**