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
This commit is contained in:
atsunatsu committed 2026-08-01 19:52:39 +08:00
1 parent 8af80866bc
commit 23d95b8241
2 files changed
+74 -52

No files matched your search

@@ -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<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _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 = ""
@@ -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()