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