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.
This commit is contained in:
mckero committed 2026-08-22 15:32:33 +00:00
1 parent 50a644f417
commit fa73328936
4 files changed
+98 -12

No files matched your search

@@ -106,6 +106,9 @@ class CwDeepDecoder(
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
private val _activeShiftHz = MutableStateFlow(0f)
override val activeShiftHz: StateFlow<Float> = _activeShiftHz.asStateFlow()
private val _signalStrength = MutableStateFlow(0f)
override val signalStrength: StateFlow<Float> = _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()
@@ -46,6 +46,9 @@ interface ICwDecoder {
/** Detected tone frequency in Hz, or null before a tone is found. */
val estimatedPitch: StateFlow<Float?>
/** Current shift applied to bring the tone into the model's window, 0f when idle. */
val activeShiftHz: StateFlow<Float>
/** Relative signal strength in 0..1 for level meters. */
val signalStrength: StateFlow<Float>
@@ -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(
@@ -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
}
/**