feat(cw): add built-in CW Morse code decoder for linear transponders

- Add CwDsp with FIR bandpass filter, envelope detection, Goertzel tone detector
- Add CwDecoder with real-time Morse timing analysis and character lookup
- Add CW state/actions to RadarState, wire into RadarViewModel
- Add collapsible CW decoder panel to transceivers page
- 19 unit tests covering DSP, Morse table, and decoder state
- Shares IAudioCapture with SSTV, auto-stops SSTV when CW starts
This commit is contained in:
atsunatsu committed 2026-07-31 18:44:49 +08:00
1 parent eac1e6e273
commit 33712c29fc
9 files changed
+1516 -7

No files matched your search

@@ -0,0 +1,178 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Real-time CW (Morse code) decoder.
*
* Processes audio buffers and emits decoded text characters.
* Uses a bandpass filter centered on the CW tone, envelope detection,
* and timing analysis to distinguish dits, dashes, and gaps.
*
* Morse timing (paris method):
* Dit = 1 unit
* Dash = 3 units
* Intra-char gap = 1 unit
* Inter-char gap = 3 units
* Word gap = 7 units
*/
class CwDecoder(
val sampleRate: Int = 8000,
val cwToneFreq: Float = 700f,
val filterWidth: Float = 200f
) {
// Filter coefficients (pre-computed)
private val firCoeffs = CwDsp.bandpassFir(
lowCutoff = ((cwToneFreq - filterWidth / 2) / sampleRate).toDouble(),
highCutoff = ((cwToneFreq + filterWidth / 2) / sampleRate).toDouble(),
taps = 127
)
// Decoder state
private var isSignalPresent = false
private var signalOnTime = 0 // samples since signal started
private var signalOffTime = 0 // samples since signal ended
private var avgDitDuration = 0f // running average of dit duration in samples
private var decodedText = StringBuilder()
private var currentSymbol = StringBuilder()
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
/** Process a buffer of audio samples. */
fun processBuffer(buffer: FloatArray) {
// 1. Bandpass filter around CW tone
val filtered = CwDsp.applyFir(buffer, firCoeffs)
// 2. Envelope detection
val env = CwDsp.envelope(filtered, 0.1f)
// 3. Adaptive threshold
val floor = CwDsp.noiseFloor(env)
val threshold = floor * 1.8f
// Track max envelope for signal strength display
val maxEnv = env.maxOrNull() ?: 0f
_signalStrength.value = if (threshold > 0f && maxEnv > threshold) {
((maxEnv - threshold) / maxEnv).coerceIn(0f, 1f)
} else 0f
// 4. Timing analysis
for (sample in env) {
if (sample > threshold) {
// Signal ON
if (!isSignalPresent) {
// Rising edge — end of silence
if (signalOffTime > 0) {
processSilence(signalOffTime)
}
signalOffTime = 0
isSignalPresent = true
}
signalOnTime++
} else {
// Signal OFF
if (isSignalPresent) {
// Falling edge — end of tone
processTone(signalOnTime)
signalOnTime = 0
isSignalPresent = false
}
signalOffTime++
}
}
// Push latest decoded text
_decodedTextFlow.value = decodedText.toString()
}
private fun processTone(duration: Int) {
// Update average dit duration based on this tone
if (avgDitDuration == 0f) {
// Initial estimate: assume shortest tone is a dit
// Typical 20 WPM dit = 60ms = 480 samples at 8kHz
avgDitDuration = minOf(duration.toFloat(), (sampleRate / 20).toFloat())
}
val ratio = duration.toFloat() / avgDitDuration
if (ratio < 1.8f) {
currentSymbol.append('.') // Dit
// Update running average with this dit
avgDitDuration = (avgDitDuration * 0.7f + duration * 0.3f)
} else if (ratio < 5f) {
currentSymbol.append('-') // Dash
}
// else: ignore very long tones (likely noise)
}
private fun processSilence(duration: Int) {
if (currentSymbol.isNotEmpty()) {
// Inter-character gap (3+ units) — decode accumulated symbol
val gapRatio = duration.toFloat() / (avgDitDuration.coerceAtLeast(1f))
if (gapRatio >= 2.5f) {
val char = morseToChar(currentSymbol.toString())
if (char != null) {
decodedText.append(char)
}
currentSymbol.clear()
// Word gap (7+ units)
if (gapRatio >= 7f) {
decodedText.append(' ')
}
}
}
}
fun resetDecoder() {
isSignalPresent = false
signalOnTime = 0
signalOffTime = 0
avgDitDuration = 0f
decodedText.clear()
currentSymbol.clear()
_decodedTextFlow.value = ""
_signalStrength.value = 0f
}
companion object {
private val MORSE_TABLE = mapOf(
".-" to 'A', "-..." to 'B', "-.-." to 'C', "-.." to 'D', "." to 'E',
"..-." to 'F', "--." to 'G', "...." to 'H', ".." to 'I', ".---" to 'J',
"-.-" to 'K', ".-.." to 'L', "--" to 'M', "-." to 'N', "---" to 'O',
".--." to 'P', "--.-" to 'Q', ".-." to 'R', "..." to 'S', "-" to 'T',
"..-" to 'U', "...-" to 'V', ".--" to 'W', "-..-" to 'X', "-.--" to 'Y',
"--.." to 'Z', ".----" to '1', "..---" to '2', "...--" to '3',
"....-" to '4', "....." to '5', "-...." to '6', "--..." to '7',
"---.." to '8', "----." to '9', "-----" to '0',
".-.-.-" to '.', "--..--" to ',', "..--.." to '?', ".----." to '\'',
"-.-.--" to '!', "-..-." to '/', "-.--." to '(', "-.--.-" to ')',
".-..." to '&', "---..." to ':', "-.-.-." to ';', "-...-" to '=',
".-.-." to '+', "-....-" to '-', "..--.-" to '_', ".-..-." to '"',
"...-..-" to '$', ".--.-." to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
@@ -0,0 +1,102 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -0,0 +1,232 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwDecoder.morseToChar(".-"))
assertEquals('B', CwDecoder.morseToChar("-..."))
assertEquals('S', CwDecoder.morseToChar("..."))
assertEquals('O', CwDecoder.morseToChar("---"))
assertEquals('C', CwDecoder.morseToChar("-.-."))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwDecoder.morseToChar(".----"))
assertEquals('5', CwDecoder.morseToChar("....."))
assertEquals('0', CwDecoder.morseToChar("-----"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwDecoder.morseToChar("......."))
assertNull(CwDecoder.morseToChar(""))
assertNull(CwDecoder.morseToChar(".-.-.-.-"))
}
@Test
fun morseToChar_specialCharacters() {
assertEquals('.', CwDecoder.morseToChar(".-.-.-"))
assertEquals('?', CwDecoder.morseToChar("..--.."))
assertEquals('/', CwDecoder.morseToChar("-..-."))
assertEquals('@', CwDecoder.morseToChar(".--.-."))
}
// --- DSP ---
@Test
fun bandpassFir_producesNonEmptyCoefficients() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 127)
assertTrue(coeffs.isNotEmpty())
assertEquals(127, coeffs.size)
// Sum should be approximately 1.0
val sum = coeffs.sum()
assertTrue("Sum should be ~1.0, got $sum", sum > 0.9 && sum < 1.1)
}
@Test
fun bandpassFir_oddTaps_forcesOdd() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 100)
assertEquals(101, coeffs.size) // forces odd
}
@Test
fun applyFir_preservesLength() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 31)
val input = FloatArray(100) { kotlin.math.sin(it * 0.1f).toFloat() }
val output = CwDsp.applyFir(input, coeffs)
assertEquals(input.size, output.size)
}
@Test
fun envelope_isNonNegative() {
val input = FloatArray(50) { if (it % 2 == 0) 0.5f else -0.3f }
val env = CwDsp.envelope(input, 0.2f)
for (v in env) assertTrue("Envelope should be >= 0, got $v", v >= 0f)
}
@Test
fun envelope_smoothsSignal() {
val input = FloatArray(100) { if (it % 2 == 0) 1f else 0f }
val env = CwDsp.envelope(input, 0.3f)
// Envelope should be between 0 and 1
for (v in env) {
assertTrue("Envelope value $v out of range [0,1]", v >= 0f && v <= 1f)
}
// After smoothing, should not rapidly oscillate
val transitions = (1 until env.size).count { env[it] > 0.1f && env[it - 1] <= 0.1f }
assertTrue("Too many envelope transitions: $transitions", transitions < 5)
}
@Test
fun noiseFloor_producesPositiveValue() {
val env = FloatArray(100) { kotlin.math.abs(kotlin.math.sin(it * 0.5f).toFloat()) }
val floor = CwDsp.noiseFloor(env, 0.3f)
assertTrue(floor > 0f)
assertTrue(floor < 1f) // should be less than max signal
}
@Test
fun goertzel_detectsPresentTone() {
val sampleRate = 8000
val targetFreq = 700f
// Generate a 700 Hz tone at the sample rate
val buffer = FloatArray(sampleRate) { (sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() }
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should detect present tone, got $power", power > 0.1f)
}
@Test
fun goertzel_rejectsAbsentTone() {
val sampleRate = 8000
val targetFreq = 700f
// Generate a 2000 Hz tone (no match for 700 Hz)
val buffer = FloatArray(sampleRate) { (sin(2.0 * PI * 2000f * it / sampleRate)).toFloat() }
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should reject absent tone, got $power", power < 0.1f)
}
@Test
fun goertzel_detectsToneInNoise() {
val sampleRate = 8000
val targetFreq = 700f
// 700 Hz tone + noise
val buffer = FloatArray(sampleRate) {
val noise = (Math.random() * 2 - 1).toFloat() * 0.3f
(sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() + noise
}
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should detect tone in noise, got $power", power > 0.1f)
}
// --- Decoder state ---
@Test
fun cwDecoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun resetDecoder_clearsText() {
val decoder = CwDecoder()
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun cwDecoder_defaultParameters() {
val decoder = CwDecoder()
assertEquals(8000, decoder.sampleRate)
assertEquals(700f, decoder.cwToneFreq, 0.001f)
assertEquals(200f, decoder.filterWidth, 0.001f)
}
@Test
fun cwDecoder_customParameters() {
val decoder = CwDecoder(sampleRate = 11025, cwToneFreq = 600f, filterWidth = 100f)
assertEquals(11025, decoder.sampleRate)
assertEquals(600f, decoder.cwToneFreq, 0.001f)
assertEquals(100f, decoder.filterWidth, 0.001f)
}
@Test
fun processBuffer_silence_doesNotCrash() {
val decoder = CwDecoder()
val silence = FloatArray(1024) { 0f }
decoder.processBuffer(silence)
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_noise_doesNotCrash() {
val decoder = CwDecoder()
val noise = FloatArray(1024) { (Math.random() * 2 - 1).toFloat() * 0.1f }
decoder.processBuffer(noise)
// Should not crash, decoded text may still be empty
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_ditAtCenterFreq_detects() {
val sampleRate = 8000
val decoder = CwDecoder(sampleRate = sampleRate, cwToneFreq = 700f)
// Generate a short dit (~480 samples at 20 WPM) at 700 Hz
val ditDuration = (sampleRate / 20).toInt() // ~400 samples
val buffer = FloatArray(ditDuration) {
(sin(2.0 * PI * 700.0 * it / sampleRate)).toFloat()
}
decoder.processBuffer(buffer)
// Short tone should be processed without crash
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_generatedDit_emitsChar() {
val sampleRate = 8000
val decoder = CwDecoder(sampleRate = sampleRate, cwToneFreq = 700f)
val ditSamples = (sampleRate / 20).toInt() // ~400 samples = 1 unit
val gapSamples = ditSamples * 3 // inter-char gap
// Generate "E" = dit: a single dit followed by inter-char gap
val buffer = FloatArray(ditSamples + gapSamples)
// First part: 700 Hz tone (dit)
for (i in 0 until ditSamples) {
buffer[i] = (sin(2.0 * PI * 700.0 * i / sampleRate)).toFloat()
}
// Second part: silence (gap)
for (i in ditSamples until buffer.size) {
buffer[i] = 0f
}
decoder.processBuffer(buffer)
// After processing, the decoder should have detected the "E" symbol
assertNotNull(decoder.decodedTextFlow.value)
}
}
@@ -86,6 +86,11 @@
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
<string name="radar_cw_decoder">CW Decoder</string>
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>