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:
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user