fix(cw): filter before decimating, so high tones stop smearing across the band

The operator reported that any tone leaked across the whole display - "even 3 kHz spreads
over the entire band, like taking a piss". The tone shifter was the suspect, since it had
been changed recently. It turned out to be innocent: the audio reaching it was already
ruined.

Capture runs at 44100 Hz and the model needs 3200 Hz, so resampleLinear decimates by a
factor of nearly 14. It interpolates between samples and nothing removes the content above
the new Nyquist of 1600 Hz first, which is the one thing decimation cannot skip. Measured on
44100 Hz input:

    3000 Hz tone  ->  ghost at  200 Hz, 119x the spectral mean
    2400 Hz tone  ->  ghost at  800 Hz
    1800 Hz tone  ->  ghost at 1400 Hz
    5000 Hz tone  ->  ghost at 1400 Hz

Each ghost is as strong as a real signal, so a tone nothing is transmitting on looks
entirely convincing. Worse for actually copying anything: the whole 1600-22050 Hz band of
hiss folds down on top of the signal and lifts the noise floor across the display. That is
the smearing.

CwAntiAlias is a 127-tap windowed-sinc low-pass, Blackman-windowed because sidelobe level is
what decides how much of the folded band survives, cut off at 92% of the target Nyquist so
the transition lands inside the discarded region. Measured suppression at the fold
frequency: 2400 Hz down 69 dB, 3000 Hz down 81 dB, 5000 Hz down 96 dB. 1800 Hz only makes
16 dB - it sits just past the 1472 Hz cut-off and 127 taps cannot be steeper without costing
more time than a phone has during a pass. The tests assert the measured numbers rather than
the ones I hoped for.

resampleLinear itself is untouched. Its comment notes it matches the reference implementation
DeepCW was trained against, so changing its arithmetic would move the spectrogram away from
what the model expects.

The streaming path holds output back by the group delay. A first attempt let the lookahead
taps read zeros at the end of each chunk, which diverged from whole-buffer filtering by
0.134 across the last 44 samples of every chunk - a click at each boundary. Holding output
back makes the two identical to within 1e-8. The cost is 63 samples, 1.4 ms, against a 20 WPM
dot of about 60 ms.

Both the decoder and the waterfall filter now. The waterfall mattered as much as the
decoder: it was showing the folded spectrum, which is what the operator was looking at.
This commit is contained in:
mckero committed 2026-08-27 14:03:30 +00:00
1 parent 8445c17033
commit 78a6f270bf
4 files changed
+437 -2

No files matched your search

@@ -24,6 +24,7 @@ import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
@@ -172,6 +173,17 @@ class CwDeepDecoder(
/** Carries Hilbert filter history and mixer phase across capture chunks. */
private val streamingShifter = CwToneShifter.Streaming()
/**
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
*
* Built on the first chunk because the capture rate is not known until then. Without
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
* folded down on top of the signal.
*/
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
/**
* Previous value of the setting, so a toggle can invalidate buffered audio.
* Null until the first chunk: a decoder created while the setting is already on
@@ -255,8 +267,18 @@ class CwDeepDecoder(
if (samples.isEmpty()) return
if (!ensureLoaded()) return
// Filter before decimating. resampleLinear interpolates without removing anything
// above the new Nyquist, so this has to happen first or the fold is already baked in.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(samples) ?: samples
// The filter holds back its group delay, so the first call returns nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val prepared = applyToneShift(resampled)
val shouldRedecode = buffer.append(prepared)
@@ -584,6 +606,7 @@ class CwDeepDecoder(
}
override fun reset() {
antiAlias?.reset()
buffer.reset()
_decodedText.value = ""
_historyText.value = ""