feat(cw): add spectral auto-tune so the decoder finds the CW tone

The fldigi port ran a fixed 600 Hz NCO, so any real signal not inside
600±75 Hz (the 150 Hz filter passband) decoded nothing — the decode rate
was effectively zero unless the tone happened to be on frequency. This
mirrors the behaviour of the removed channelTracker: a sliding spectral
peak detector now steers the NCO to the strongest tone.

Changes:
- Collect raw input, run a 512-pt Hann-windowed FFT every frame, find
  the strongest bin in 300..1500 Hz (CW range), smooth-track it.
- First strong peak locks immediately (no RX reset, so the triggering
  element survives); later large jumps (>120 Hz) retune and reset the
  fldigi state machine; small drifts are eased at 20%.
- Absolute energy floor (peak < 30) so silence/noise never steers.
- estimatedPitch now reflects the tracked tone frequency.

Verification:
- New unit test: 900 Hz "CQ" with decoder initialized at 600 Hz decodes
  correctly and pitch moves to ~900 Hz.
- All 9 decoder tests pass; full domain/cw/radar test suites green.
This commit is contained in:
mckero committed 2026-08-09 05:04:48 +00:00
1 parent ec40f29f28
commit 09ebf1f39a
2 files changed
+137 -2

No files matched your search

@@ -47,6 +47,12 @@ class CwFldigiDecoder(
const val CW_QUERY = 3
const val CW_SUCCESS = 0
const val CW_ERROR = -1
// Auto-tune (spectral peak tracking, mirrors the old channelTracker)
const val TUNE_FFT_SIZE = 512
const val TUNE_MIN_FREQ = 300.0
const val TUNE_MAX_FREQ = 1500.0
const val TUNE_ENERGY_FRACTION = 0.35
}
private enum class CwRxState { IDLE, IN_TONE, AFTER_TONE }
@@ -64,6 +70,16 @@ class CwFldigiDecoder(
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
// --- auto-tune state (spectral peak tracking) ---
private var tuneFreq = frequency
private val tuneBuffer = DoubleArray(TUNE_FFT_SIZE)
private var tuneIdx = 0
private var tuneFft = CwGfft(TUNE_FFT_SIZE)
private var tunePeakFreq = frequency
private var tuneHasPeak = false
private var tuneLocked = false
private val tuneWindow = DoubleArray(TUNE_FFT_SIZE)
// --- fldigi cw state ---
private var phaseacc = 0.0
private var FFTphase = 0.0
@@ -128,17 +144,99 @@ class CwFldigiDecoder(
/** Main entry: feed PCM samples. */
fun processBuffer(buffer: FloatArray) {
for (sample in buffer) {
feedTuner(sample.toDouble())
rxSample(sample.toDouble())
}
}
/**
* Spectral peak tracking (auto-tune). Collects raw samples, runs an FFT
* every TUNE_FFT_SIZE samples, finds the strongest peak in the CW range
* and smoothly steers tuneFreq toward it — mirroring the old
* channelTracker behaviour so off-tune signals still decode.
*/
private fun feedTuner(sample: Double) {
tuneBuffer[tuneIdx++] = sample
if (tuneIdx < TUNE_FFT_SIZE) return
tuneIdx = 0
// Hann window
for (i in 0 until TUNE_FFT_SIZE) {
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (TUNE_FFT_SIZE - 1))
tuneWindow[i] = tuneBuffer[i] * w
}
val data = Array(TUNE_FFT_SIZE) { CwComplex(tuneWindow[it], 0.0) }
tuneFft.forward(data)
// Find the strongest bin in the CW range (300..1500 Hz)
val binMin = (TUNE_MIN_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
val binMax = (TUNE_MAX_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
var bestBin = -1
var bestMag = 0.0
var totalMag = 0.0
for (b in binMin..binMax) {
val mag = data[b].abs()
totalMag += mag
if (mag > bestMag) {
bestMag = mag
bestBin = b
}
}
if (bestBin < 0) return
val meanMag = totalMag / (binMax - binMin + 1)
if (bestMag < meanMag * 2.0) return // no clear tone
// Absolute floor: silence or weak noise must not steer the NCO.
// A 0.6-amplitude tone in a 512-pt Hann FFT yields peak ≈ 150;
// anything below ~30 is noise/DC leakage.
if (bestMag < 30.0) return
val peakFreq = bestBin * sampleRate.toDouble() / TUNE_FFT_SIZE
tunePeakFreq = peakFreq
tuneHasPeak = true
_estimatedPitch.value = peakFreq.toFloat()
// Lock fast: a strong peak on the very first frame is reliable enough
// (CW tones are narrow and dominate the band). Steer immediately so
// the first character still decodes. Do NOT reset the RX state on the
// first lock — the AGC adapts in a few frames and a reset would wipe
// the element that triggered the tune.
if (!tuneLocked) {
tuneFreq = peakFreq
tuneLocked = true
_estimatedPitch.value = peakFreq.toFloat()
} else {
// Smooth tracking; retune instantly on big jumps (signal switched freq)
val diff = peakFreq - tuneFreq
if (kotlin.math.abs(diff) > 120.0) {
tuneFreq = peakFreq
resetRxState()
} else {
tuneFreq += diff * 0.2
}
}
}
/** Reset only the fldigi RX state machine (keep decoded text). */
private fun resetRxState() {
cw_receive_state = CwRxState.IDLE
old_cw_receive_state = CwRxState.IDLE
smpl_ctr = 0
cw_ptr = 0
rx_rep_buf.clear()
last_element = 0
space_sent = true
FFTphase = 0.0
phaseacc = 0.0
}
private fun rxSample(value: Double) {
// NCO down-conversion (fldigi rx_FFTprocess)
// NCO down-conversion (fldigi rx_FFTprocess). tuneFreq tracks the
// strongest spectral peak so off-tune signals still decode.
val z = CwComplex(
value * kotlin.math.cos(FFTphase),
value * kotlin.math.sin(FFTphase)
)
FFTphase += 2.0 * Math.PI * frequency / sampleRate
FFTphase += 2.0 * Math.PI * tuneFreq / sampleRate
if (FFTphase > 2.0 * Math.PI) FFTphase -= 2.0 * Math.PI
val out = cw_FFT_filter.run(z) ?: return
@@ -422,5 +520,11 @@ class CwFldigiDecoder(
_estimatedSpeed.value = null
FFTphase = 0.0
phaseacc = 0.0
// reset auto-tune
tuneIdx = 0
tunePeakFreq = frequency
tuneHasPeak = false
tuneLocked = false
tuneFreq = frequency
}
}
@@ -146,4 +146,35 @@ class CwFldigiDecoderTest {
val maxVal = out.maxOrNull() ?: 0.0
assertTrue("expected signal to pass through lowpass, max=$maxVal", maxVal > 0.05)
}
@Test
fun autoTune_decodesOffFreqSignal() {
// Signal at 900 Hz but decoder initialized at 600 Hz: auto-tune must
// steer the NCO to the real tone, otherwise nothing decodes.
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = 600.0)
val dotLen = CwFldigiConstants.KWPM / 18
val synth = { ch: Char ->
val len = if (ch == '.') dotLen else 3 * dotLen
FloatArray(len) { i ->
val t = i.toDouble() / sampleRate
(0.6 * sin(2.0 * PI * 900.0 * t)).toFloat()
}
}
// "CQ" at 900 Hz: -.-. --.-
val text = StringBuilder()
val seq = listOf(listOf('-', '.', '-', '.'), listOf('-', '-', '.', '-'))
for ((ci, word) in seq.withIndex()) {
for ((i, ch) in word.withIndex()) {
decoder.processBuffer(synth(ch))
if (i < word.size - 1) decoder.processBuffer(FloatArray(dotLen))
}
if (ci < seq.size - 1) decoder.processBuffer(FloatArray(3 * dotLen))
}
decoder.processBuffer(FloatArray(6 * dotLen))
val result = decoder.decodedTextFlow.value
assertTrue("expected CQ from 900 Hz signal (auto-tune), got: $result", result.contains("C") && result.contains("Q"))
// pitch should have moved toward 900 Hz
val pitch = decoder.estimatedPitch.value ?: 0f
assertTrue("expected pitch near 900, got $pitch", pitch > 800f && pitch < 1000f)
}
}