From 23d95b824167eb8efc56199e61970d54cdc62cb9 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 1 Aug 2026 19:52:39 +0800 Subject: [PATCH] fix(cw): fix timing analysis - process per spectrogram column - Add newColumnCount tracking to CwSpectrogram - CwDecoder now processes each new column individually for timing - Each column = 8ms at 8000 Hz sample rate - Proper per-column iteration through spectrogram history --- .../look4sat/core/domain/cw/CwDecoder.kt | 106 +++++++++--------- .../look4sat/core/domain/cw/CwSpectrogram.kt | 20 +++- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt index c261d521..b358846a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt @@ -30,19 +30,21 @@ import kotlinx.coroutines.flow.StateFlow * 4. Bayesian probability for symbol timing (Gaussian likelihood) * 5. Best channel selected for output * - * Key advantages over v2 (ggmorse): - * - Frequency-agnostic: monitors all 200-1200 Hz simultaneously - * - Multi-channel: tracks up to 3 signals in parallel - * - Bayesian: probability-based decisions, not hard thresholds - * - Frequency drift tolerant: energy just moves between bins + * Timing analysis is performed per spectrogram column (hop). + * Each column represents hopSize/sampleRate seconds of audio. */ class CwDecoder( val sampleRate: Int = 8000, cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram) ) { + companion object { + private const val FFT_SIZE = 256 + private const val HOP_SIZE = 64 + } + private val spectrogram = CwSpectrogram( - fftSize = 256, - hopSize = 64, + fftSize = FFT_SIZE, + hopSize = HOP_SIZE, sampleRate = sampleRate, minBin = 6, maxBin = 38, @@ -51,18 +53,16 @@ class CwDecoder( private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3) private val bayesianDecoder = CwBayesianDecoder() - // Per-channel state + // Timing state per channel private data class ChannelTiming( var isSignal: Boolean = false, - var toneSamples: Int = 0, - var gapSamples: Int = 0 + var toneTicks: Int = 0, + var gapTicks: Int = 0 ) private val timingStates = Array(3) { ChannelTiming() } - // Sample period in milliseconds (at 4 kHz effective rate for timing) - // The spectrogram processes at native sample rate, but timing analysis - // uses the spectrogram column rate: hopSize/sampleRate seconds per column - private val samplePeriodMs = 1000f * hopSize / sampleRate + // Time per spectrogram column in milliseconds + private val tickMs = 1000f * HOP_SIZE / sampleRate // Output flows private val _decodedTextFlow = MutableStateFlow("") @@ -77,7 +77,6 @@ class CwDecoder( private val _estimatedSpeed = MutableStateFlow(null) val estimatedSpeed: StateFlow = _estimatedSpeed - // Frame counter for periodic updates private var frameCount = 0 init { @@ -86,53 +85,58 @@ class CwDecoder( } } - companion object { - private const val hopSize = 64 - } - fun processBuffer(buffer: FloatArray) { - // 1. Feed samples to spectrogram (generates FFT waterfall) + // 1. Feed samples to spectrogram spectrogram.addSamples(buffer) - // 2. Update channel tracker (find active frequency bins) + // 2. Get number of new columns generated + val newCols = spectrogram.getNewColumns() + if (newCols == 0) return + + // 3. Update channel tracker (uses latest column for peak detection) val activeChannels = channelTracker.update() - // 3. For each active channel, extract timing - for ((idx, channel) in activeChannels.withIndex()) { - if (idx >= timingStates.size) break - val state = timingStates[idx] - val col = spectrogram.getCurrentColumn() - val energy = if (channel.bin in col.indices) col[channel.bin] else 0f + // 4. Process each new column for timing analysis + // Columns are indexed 0..historyCols-1, where historyCols-1 is the newest + val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0) + for (colOffset in 0 until newCols) { + val col = spectrogram.getColumn(baseIdx + colOffset) - // Adaptive threshold: 30% above noise floor - val threshold = 0.3f + (energy - 0.3f) * 0.3f + for ((idx, channel) in activeChannels.withIndex()) { + if (idx >= timingStates.size) break + val state = timingStates[idx] + val energy = if (channel.bin in col.indices) col[channel.bin] else 0f - if (energy > threshold) { - if (!state.isSignal) { - // Rising edge — process gap - if (state.gapSamples > 0) { - val gapMs = state.gapSamples * samplePeriodMs - bayesianDecoder.processGap(gapMs) + // Adaptive threshold + val threshold = 0.3f + (energy - 0.3f) * 0.3f + + if (energy > threshold) { + if (!state.isSignal) { + if (state.gapTicks > 0) { + val gapMs = state.gapTicks * tickMs + bayesianDecoder.processGap(gapMs) + } + state.gapTicks = 0 + state.isSignal = true } - state.gapSamples = 0 - state.isSignal = true + state.toneTicks++ + } else { + if (state.isSignal) { + if (state.toneTicks > 0) { + val toneMs = state.toneTicks * tickMs + bayesianDecoder.processTone(toneMs) + } + state.toneTicks = 0 + state.isSignal = false + } + state.gapTicks++ } - state.toneSamples++ - } else { - if (state.isSignal) { - // Falling edge — process tone - val toneMs = state.toneSamples * samplePeriodMs - bayesianDecoder.processTone(toneMs) - state.toneSamples = 0 - state.isSignal = false - } - state.gapSamples++ } } - // 4. Update outputs from best channel + // 5. Update outputs frameCount++ - if (frameCount % 5 == 0) { // Every 5 frames + if (frameCount % 5 == 0) { val bestChannel = channelTracker.getBestChannel() if (bestChannel != null) { _estimatedPitch.value = bestChannel.frequency @@ -149,8 +153,8 @@ class CwDecoder( bayesianDecoder.reset() for (state in timingStates) { state.isSignal = false - state.toneSamples = 0 - state.gapSamples = 0 + state.toneTicks = 0 + state.gapTicks = 0 } frameCount = 0 _decodedTextFlow.value = "" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt index 9ff9a352..fa5676b1 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt @@ -32,7 +32,7 @@ internal class CwSpectrogram( private val sampleRate: Int = 4000, private val minBin: Int = 6, private val maxBin: Int = 38, - private val historyCols: Int = 40 + val historyCols: Int = 40 ) { private val fft = CwFFT(fftSize) val numBins: Int get() = maxBin - minBin + 1 @@ -52,6 +52,9 @@ internal class CwSpectrogram( private val binEnergy = FloatArray(numBins) { 1f } private val alpha = 0.95f + // Counter for new columns generated since last check + private var newColumnCount = 0 + /** Add audio samples, compute FFTs for each complete hop. */ fun addSamples(samples: FloatArray) { var offset = 0 @@ -64,6 +67,7 @@ internal class CwSpectrogram( if (samplesBuffered >= fftSize) { processFrame() + newColumnCount++ // Shift buffer: keep last (fftSize - hopSize) samples System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize) samplesBuffered = fftSize - hopSize @@ -71,6 +75,13 @@ internal class CwSpectrogram( } } + /** Get number of new columns generated since the last call to this method. */ + fun getNewColumns(): Int { + val count = newColumnCount + newColumnCount = 0 + return count + } + private fun processFrame() { // Apply Hanning window val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] } @@ -106,6 +117,13 @@ internal class CwSpectrogram( return spectrogram[prevCol].copyOf() } + /** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */ + fun getColumn(index: Int): FloatArray { + val clamped = index.coerceIn(0, historyCols - 1) + val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols + return spectrogram[srcIdx].copyOf() + } + /** Find the frequency bin with peak energy. Returns -1 if no significant signal. */ fun findPeakBin(): Int { val col = getCurrentColumn()