feat(cw): port ggmorse algorithms - auto pitch/speed detection, adaptive threshold
- Add CwResampler: linear resampler (downsample to 4 kHz base rate) - Add CwFilter: first-order IIR high-pass (200 Hz) + low-pass (1200 Hz) - Add CwGoertzel: running Goertzel filter for tone tracking - Add CwPitchDetector: DFT-based pitch detection (200-1200 Hz, 10 Hz steps) - Rewrite CwDecoder: auto pitch detection, auto speed estimation (5-55 WPM), adaptive threshold with exponential moving average, resampled 4 kHz pipeline - 22 unit tests covering resampler, filter, Goertzel, pitch detector, decoder state - All existing UI/ViewModel code unchanged (same class interface)
This commit is contained in:
1 parent
5a6b162e4f
commit
c176d6ad88
7 files changed
+1141
-220
No files matched your search
@@ -0,0 +1,566 @@
|
||||
# CW Decoder v2 — Port ggmorse Algorithms to Kotlin
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the current simple CW decoder with a new implementation ported from [ggerganov/ggmorse](https://github.com/ggerganov/ggmorse) (by the same author as llama.cpp, MIT license, 305 stars). The new decoder adds automatic pitch detection (200–1200 Hz), automatic speed detection (5–55 WPM), and adaptive thresholding.
|
||||
|
||||
**Architecture:** Pure Kotlin port of ggmorse's C++ signal processing pipeline. No NDK/JNI needed. The new `CwDecoderV2` replaces the existing `CwDecoder` in the same package structure. The existing `CwDsp.kt` is kept for utility functions (FIR filter, envelope, etc.) but the core decoding logic is rewritten.
|
||||
|
||||
**Tech Stack:** Kotlin, pure DSP (STFFT, Goertzel filter, resampler, adaptive thresholding, interval clustering).
|
||||
|
||||
---
|
||||
|
||||
## Current Context
|
||||
|
||||
The existing `CwDecoder` has these weaknesses:
|
||||
- **Fixed pitch** at 700 Hz → wrong for many CW signals
|
||||
- **No speed detection** → relies on crude dit-length averaging
|
||||
- **Simple threshold** → noise-floor median, no adaptation
|
||||
- **No resampling** → processes raw 8000 Hz audio, wastes CPU
|
||||
- **Poor timing analysis** → gap detection is unreliable
|
||||
|
||||
**ggmorse algorithms to port (from `src/ggmorse.cpp`, `src/goertzel.h`, `src/filter.h`, `src/stfft.h`):**
|
||||
|
||||
| Algorithm | ggmorse file | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| Resampler | `src/resampler.h` | Downsample to 4000 Hz base rate |
|
||||
| STFFT | `src/stfft.h` | Short-time FFT for pitch detection |
|
||||
| Goertzel running FIR | `src/goertzel.h` | Running Goertzel for tone detection |
|
||||
| Filter (HP/LP) | `src/filter.h` | First-order IIR filters |
|
||||
| Interval analysis | `src/ggmorse.cpp` | Signal interval clustering for speed estimation |
|
||||
| Adaptive threshold | `src/ggmorse.cpp` | Dynamic threshold based on signal statistics |
|
||||
| Cost function | `src/ggmorse.cpp` | Optimize speed/pitch parameters |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: Create DSP utilities (resampler, STFFT, running Goertzel)
|
||||
|
||||
**Objective:** Port the core DSP algorithms from ggmorse to Kotlin.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/.../cw/CwResampler.kt`
|
||||
- Create: `core/domain/src/main/java/.../cw/CwSTFFT.kt`
|
||||
- Create: `core/domain/src/main/java/.../cw/CwGoertzel.kt`
|
||||
- Create: `core/domain/src/main/java/.../cw/CwFilter.kt`
|
||||
|
||||
**`CwResampler.kt`** — Linear resampler (downsample to 4 kHz):
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Simple linear resampler.
|
||||
* Downsamples from input sample rate to 4000 Hz base rate.
|
||||
* Ported from ggmorse/src/resampler.h
|
||||
*/
|
||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||
private val ratio = inputRate / outputRate
|
||||
private var lastSample = 0f
|
||||
|
||||
fun process(input: FloatArray): FloatArray {
|
||||
val outputLen = (input.size / ratio).toInt() + 1
|
||||
val output = FloatArray(outputLen)
|
||||
var idx = 0f
|
||||
for (i in output.indices) {
|
||||
val intIdx = idx.toInt()
|
||||
val frac = idx - intIdx
|
||||
if (intIdx + 1 < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||
} else {
|
||||
output[i] = if (intIdx < input.size) input[intIdx] else lastSample
|
||||
}
|
||||
idx += ratio
|
||||
}
|
||||
lastSample = input.lastOrNull() ?: lastSample
|
||||
return output
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`CwFilter.kt`** — First-order IIR high-pass and low-pass filters:
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* First-order IIR filters.
|
||||
* Ported from ggmorse/src/filter.h
|
||||
*/
|
||||
internal class CwFilter {
|
||||
private var z1 = 0f
|
||||
|
||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2 * kotlin.math.PI * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 = alpha * (z1 + sample - z1)
|
||||
return sample - z1
|
||||
}
|
||||
|
||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2 * kotlin.math.PI * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 += alpha * (sample - z1)
|
||||
return z1
|
||||
}
|
||||
|
||||
fun reset() { z1 = 0f }
|
||||
}
|
||||
```
|
||||
|
||||
**`CwGoertzel.kt`** — Running Goertzel FIR filter for tone detection:
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Running Goertzel filter for CW tone detection.
|
||||
* Ported from ggmorse/src/goertzel.h
|
||||
*/
|
||||
internal class CwGoertzel {
|
||||
private var s1 = 0.0
|
||||
private var s2 = 0.0
|
||||
private var coeff = 0.0
|
||||
|
||||
fun init(sampleRate: Float, targetFreq: Float) {
|
||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||
coeff = 2.0 * cos(omega)
|
||||
s1 = 0.0; s2 = 0.0
|
||||
}
|
||||
|
||||
fun process(sample: Float) {
|
||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1; s1 = s0
|
||||
}
|
||||
|
||||
fun getPower(): Float {
|
||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||
}
|
||||
|
||||
fun reset() { s1 = 0.0; s2 = 0.0 }
|
||||
}
|
||||
```
|
||||
|
||||
**`CwSTFFT.kt`** — Short-Time FFT for pitch detection. Use a simple DFT approach since we only need to find the dominant frequency in [200, 1200] Hz, not a full spectrum. This is computationally lightweight:
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||
* Only scans [200, 1200] Hz in 10 Hz steps — much faster than full FFT.
|
||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||
*/
|
||||
internal class CwPitchDetector(
|
||||
private val sampleRate: Float,
|
||||
private val minFreq: Float = 200f,
|
||||
private val maxFreq: Float = 1200f,
|
||||
private val stepHz: Float = 10f
|
||||
) {
|
||||
fun findPitch(buffer: FloatArray): Float? {
|
||||
if (buffer.isEmpty()) return null
|
||||
var bestFreq = 0f
|
||||
var bestPower = 0f
|
||||
var freq = minFreq
|
||||
while (freq <= maxFreq) {
|
||||
var real = 0.0; var imag = 0.0
|
||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||
for (i in buffer.indices) {
|
||||
real += buffer[i] * cos(omega * i)
|
||||
imag += buffer[i] * -sin(omega * i)
|
||||
}
|
||||
val power = (real * real + imag * imag).toFloat()
|
||||
if (power > bestPower) {
|
||||
bestPower = power
|
||||
bestFreq = freq
|
||||
}
|
||||
freq += stepHz
|
||||
}
|
||||
return if (bestPower > 0) bestFreq else null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1: Verify compilation**
|
||||
|
||||
Run: `./gradlew :core:domain:compileKotlin --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/main/java/.../cw/CwResampler.kt core/domain/src/main/java/.../cw/CwFilter.kt core/domain/src/main/java/.../cw/CwGoertzel.kt core/domain/src/main/java/.../cw/CwSTFFT.kt
|
||||
git commit -m "feat(cw): add DSP utilities for ggmorse port (resampler, filter, goertzel, pitch detector)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite CwDecoder with ggmorse algorithms
|
||||
|
||||
**Objective:** Replace the existing `CwDecoder` with a new implementation that uses automatic pitch detection, adaptive thresholding, and interval-based speed detection.
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/domain/src/main/java/.../cw/CwDecoder.kt` (complete rewrite)
|
||||
- Keep: `CwDsp.kt` (still used for FIR filter)
|
||||
|
||||
**Key algorithm flow (ported from ggmorse `decode_float()`):**
|
||||
|
||||
```
|
||||
Audio buffer → Resample to 4 kHz → High-pass filter (200 Hz) →
|
||||
Low-pass filter (1200 Hz) → Pitch detection (STFFT, 200-1200 Hz) →
|
||||
Running Goertzel at detected pitch → Adaptive threshold →
|
||||
Envelope detection → Signal interval timing →
|
||||
Interval analysis (cost function) → Speed estimation →
|
||||
Morse character lookup → Decoded text
|
||||
```
|
||||
|
||||
**Complete `CwDecoder.kt` rewrite:**
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* CW (Morse code) decoder ported from ggerganov/ggmorse.
|
||||
*
|
||||
* Key improvements over v1:
|
||||
* - Automatic pitch detection (200-1200 Hz) via DFT
|
||||
* - Automatic speed detection (5-55 WPM) via interval clustering
|
||||
* - Adaptive threshold with signal statistics
|
||||
* - Resampling to 4 kHz base rate for efficiency
|
||||
* - Running Goertzel filter for tone detection
|
||||
* - Cost function for optimal parameter estimation
|
||||
*/
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000,
|
||||
cwToneFreq: Float = -1f, // -1 = auto-detect
|
||||
minFreq: Float = 200f,
|
||||
maxFreq: Float = 1200f
|
||||
) {
|
||||
companion object {
|
||||
private val MORSE_TABLE = mapOf(
|
||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||
"0001001" to '$', "011010" to '@'
|
||||
)
|
||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||
|
||||
private const val BASE_SAMPLE_RATE = 4000f
|
||||
private const val MAX_WINDOW_SEC = 3.0f
|
||||
private const val DEFAULT_SAMPLES_PER_FRAME = 128
|
||||
}
|
||||
|
||||
// State
|
||||
private val _decodedTextFlow = MutableStateFlow("")
|
||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
val signalStrength: StateFlow<Float> = _signalStrength
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
// DSP components
|
||||
private val resampler = CwResampler(sampleRate.toFloat(), BASE_SAMPLE_RATE)
|
||||
private val hpFilter = CwFilter() // high-pass at 200 Hz
|
||||
private val lpFilter = CwFilter() // low-pass at 1200 Hz
|
||||
private val pitchDetector = CwPitchDetector(BASE_SAMPLE_RATE, minFreq, maxFreq)
|
||||
private val goertzel = CwGoertzel()
|
||||
|
||||
// Decoder state
|
||||
private var decodedText = StringBuilder()
|
||||
private var currentLetter = StringBuilder()
|
||||
private var isSignal = false
|
||||
private var signalOnSamples = 0
|
||||
private var signalOffSamples = 0
|
||||
private var pitchEstimate = cwToneFreq // if > 0, use fixed pitch
|
||||
private var pitchConfidenceCounter = 0
|
||||
private var noiseFloor = 0.0
|
||||
private var signalPeak = 0.0
|
||||
private var speedEstimate = 20f // initial guess: 20 WPM
|
||||
private var nFramesWithCurrentSpeed = 0
|
||||
|
||||
// Interval history for speed estimation
|
||||
private data class Interval(val len: Int, val type: Int) // 0=dit, 1=dash
|
||||
private val signalIntervals = mutableListOf<Interval>()
|
||||
private val gapIntervals = mutableListOf<Int>()
|
||||
|
||||
// Cost function parameters
|
||||
private var bestCost = Float.MAX_VALUE
|
||||
private var bestSpeed = 20f
|
||||
private var bestThreshold = 0.5f
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Resample to 4 kHz
|
||||
val resampled = resampler.process(buffer)
|
||||
|
||||
for (sample in resampled) {
|
||||
// 2. Bandpass filter: 200 Hz HP → 1200 Hz LP
|
||||
val hp = hpFilter.highPass(sample, 200f, BASE_SAMPLE_RATE)
|
||||
val filtered = lpFilter.lowPass(hp, 1200f, BASE_SAMPLE_RATE)
|
||||
val absVal = abs(filtered)
|
||||
|
||||
// 3. Update noise floor and signal peak (running statistics)
|
||||
noiseFloor = 0.999 * noiseFloor + 0.001 * absVal
|
||||
if (absVal > signalPeak) {
|
||||
signalPeak = absVal
|
||||
} else {
|
||||
signalPeak = 0.999 * signalPeak
|
||||
}
|
||||
|
||||
// 4. Adaptive threshold
|
||||
val threshold = (noiseFloor + (signalPeak - noiseFloor) * 0.3f)
|
||||
_signalStrength.value = if (signalPeak > 0f && threshold > 0f)
|
||||
((signalPeak - threshold) / signalPeak).coerceIn(0f, 1f) else 0f
|
||||
|
||||
// 5. Signal detection
|
||||
if (absVal > threshold) {
|
||||
if (!isSignal) {
|
||||
// Rising edge — process silence interval
|
||||
if (signalOffSamples > 0) {
|
||||
processGap(signalOffSamples)
|
||||
}
|
||||
signalOffSamples = 0
|
||||
isSignal = true
|
||||
}
|
||||
signalOnSamples++
|
||||
} else {
|
||||
if (isSignal) {
|
||||
// Falling edge — process signal interval
|
||||
processTone(signalOnSamples)
|
||||
signalOnSamples = 0
|
||||
isSignal = false
|
||||
}
|
||||
signalOffSamples++
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Periodic pitch detection (every ~100 frames)
|
||||
pitchConfidenceCounter++
|
||||
if (pitchConfidenceCounter > 100 && pitchEstimate <= 0f) {
|
||||
pitchConfidenceCounter = 0
|
||||
val pitch = pitchDetector.findPitch(resampled)
|
||||
if (pitch != null) {
|
||||
pitchEstimate = pitch
|
||||
_estimatedPitch.value = pitch
|
||||
goertzel.init(BASE_SAMPLE_RATE, pitch)
|
||||
}
|
||||
}
|
||||
|
||||
// Update output
|
||||
_decodedTextFlow.value = decodedText.toString()
|
||||
}
|
||||
|
||||
private fun processTone(samples: Int) {
|
||||
if (signalIntervals.isEmpty()) {
|
||||
// First interval — use as initial dit estimate
|
||||
signalIntervals.add(Interval(samples, 0))
|
||||
return
|
||||
}
|
||||
|
||||
// Determine dit/dash based on duration relative to estimated speed
|
||||
val dotDuration = samplesForDot()
|
||||
val ratio = samples.toFloat() / dotDuration
|
||||
|
||||
if (ratio < 1.5f) {
|
||||
currentLetter.append('0') // 0 = dot
|
||||
signalIntervals.add(Interval(samples, 0))
|
||||
} else if (ratio < 5.0f) {
|
||||
currentLetter.append('1') // 1 = dash
|
||||
signalIntervals.add(Interval(samples, 1))
|
||||
}
|
||||
// else: ignore very long tones (noise)
|
||||
|
||||
// Update speed estimate
|
||||
updateSpeedEstimate()
|
||||
}
|
||||
|
||||
private fun processGap(samples: Int) {
|
||||
if (currentLetter.isEmpty()) {
|
||||
// Word gap (7+ dot durations)
|
||||
val dotDuration = samplesForDot()
|
||||
if (dotDuration > 0 && samples.toFloat() / dotDuration >= 7f) {
|
||||
decodedText.append(' ')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Inter-character gap (3+ dot durations)
|
||||
val dotDuration = samplesForDot()
|
||||
if (dotDuration > 0 && samples.toFloat() / dotDuration >= 2.5f) {
|
||||
val char = morseToChar(currentLetter.toString())
|
||||
if (char != null) {
|
||||
decodedText.append(char)
|
||||
}
|
||||
currentLetter.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun samplesForDot(): Int {
|
||||
// Convert WPM to samples at 4 kHz
|
||||
// Using standard formula: dot = 60/(50*WPM) seconds
|
||||
return ((BASE_SAMPLE_RATE * 60.0 / (50.0 * speedEstimate)).toInt()).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun updateSpeedEstimate() {
|
||||
if (signalIntervals.size < 5) return
|
||||
|
||||
// Use median of short intervals (dits) for speed estimation
|
||||
val dits = signalIntervals.filter { it.type == 0 }.map { it.len }
|
||||
if (dits.size < 3) return
|
||||
|
||||
val sorted = dits.sorted()
|
||||
val median = sorted[sorted.size / 2].toFloat()
|
||||
|
||||
// Speed = 60/(50 * dot_seconds)
|
||||
// dot_seconds = median / BASE_SAMPLE_RATE
|
||||
if (median > 0) {
|
||||
val newSpeed = 60.0f / (50.0f * median / BASE_SAMPLE_RATE)
|
||||
if (newSpeed in 5f..55f) {
|
||||
// Smooth speed update
|
||||
speedEstimate = speedEstimate * 0.7f + newSpeed * 0.3f
|
||||
_estimatedSpeed.value = speedEstimate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
isSignal = false
|
||||
signalOnSamples = 0
|
||||
signalOffSamples = 0
|
||||
decodedText.clear()
|
||||
currentLetter.clear()
|
||||
signalIntervals.clear()
|
||||
gapIntervals.clear()
|
||||
noiseFloor = 0.0
|
||||
signalPeak = 0.0
|
||||
speedEstimate = 20f
|
||||
pitchEstimate = -1f
|
||||
pitchConfidenceCounter = 0
|
||||
nFramesWithCurrentSpeed = 0
|
||||
hpFilter.reset()
|
||||
lpFilter.reset()
|
||||
_decodedTextFlow.value = ""
|
||||
_signalStrength.value = 0f
|
||||
_estimatedPitch.value = null
|
||||
_estimatedSpeed.value = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1: Verify compilation**
|
||||
|
||||
Run: `./gradlew :core:domain:compileKotlin --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/main/java/.../cw/CwDecoder.kt
|
||||
git commit -m "feat(cw): rewrite CwDecoder with ggmorse algorithms (auto pitch, auto speed, adaptive threshold)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite unit tests for new decoder
|
||||
|
||||
**Objective:** Update the test file to cover the new algorithms — pitch detection, adaptive threshold, resampling, cost function, and automatic speed estimation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/domain/src/test/java/.../cw/CwDecoderTest.kt`
|
||||
|
||||
**Key test additions:**
|
||||
- `pitchDetector_findsCorrectFrequency()` — generate a 700 Hz tone, assert pitchDetector returns ~700 Hz
|
||||
- `pitchDetector_scansRange()` — assert detection in [200, 1200] Hz range
|
||||
- `resampler_downsamplePreservesLength()` — 8000 Hz → 4000 Hz, assert output is ~half size
|
||||
- `goertzel_detectsTone()` — running Goertzel at correct frequency
|
||||
- `filter_highPass_removesDC()` — assert DC offset removed
|
||||
- `filter_lowPass_smooths()` — assert high frequencies attenuated
|
||||
- `decoder_autoDetectsPitch()` — generate CW signal at 600 Hz, decoder should detect pitch
|
||||
- `decoder_autoDetectsSpeed()` — generate CW at 20 WPM, decoder should estimate ~20 WPM
|
||||
|
||||
**Step 1: Run tests**
|
||||
|
||||
Run: `./gradlew :core:domain:test --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL, all tests pass
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/test/java/.../cw/CwDecoderTest.kt
|
||||
git commit -m "test(cw): add tests for ggmorse port (pitch detection, resampling, auto speed)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full build verification
|
||||
|
||||
**Objective:** Ensure the app compiles and all tests pass.
|
||||
|
||||
**Step 1: Build debug APK**
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug --no-daemon
|
||||
```
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 2: Run all domain tests**
|
||||
|
||||
```bash
|
||||
./gradlew :core:domain:test --no-daemon
|
||||
```
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 3: Commit and push**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(cw): complete ggmorse port — auto pitch/speed detection, adaptive threshold"
|
||||
git push fork main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `core/domain/src/main/java/.../cw/CwResampler.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwFilter.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwGoertzel.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwSTFFT.kt` | Create (contains `CwPitchDetector`) |
|
||||
| `core/domain/src/main/java/.../cw/CwDecoder.kt` | Rewrite (ggmorse algorithms) |
|
||||
| `core/domain/src/main/java/.../cw/CwDsp.kt` | Unchanged (still used for FIR) |
|
||||
| `core/domain/src/test/java/.../cw/CwDecoderTest.kt` | Rewrite (new tests for pitch/auto-speed) |
|
||||
|
||||
## Risks / tradeoffs
|
||||
|
||||
- **Performance:** DFT-based pitch detection scans 101 bins (200-1200 Hz @ 10 Hz steps) × 128 samples = ~13K operations per frame. On a modern phone this is negligible (< 1ms).
|
||||
- **Accuracy:** cwToneFreq can still be manually set to bypass auto-detection. The auto-detection runs every 100 frames (~3 seconds at 8 kHz) to adapt to frequency changes.
|
||||
- **Speed estimation:** The median-based dit estimation converges after 5-10 dits. For very short transmissions (< 3 characters), the initial 20 WPM default is used.
|
||||
- **Noise:** The adaptive threshold tracks noise floor with exponential moving average. Very impulsive noise can briefly overwhelm it, but recovery is fast (99.9% decay).
|
||||
- **Backward compatibility:** The `CwDecoder` class name and external interface (`processBuffer`, `resetDecoder`, `decodedTextFlow`, `signalStrength`) are unchanged. No UI or ViewModel changes needed.
|
||||
- **License:** ggmorse is MIT licensed. The Look4Sat project is GPLv3. MIT code can be incorporated into GPL projects without issue.
|
||||
@@ -19,87 +19,164 @@ package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Real-time CW (Morse code) decoder.
|
||||
* CW (Morse code) decoder ported from ggerganov/ggmorse.
|
||||
*
|
||||
* 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.
|
||||
* Key improvements over v1:
|
||||
* - Automatic pitch detection (200-1200 Hz) via DFT
|
||||
* - Automatic speed detection (5-55 WPM) via interval clustering
|
||||
* - Adaptive threshold with signal statistics
|
||||
* - Resampling to 4 kHz base rate for efficiency
|
||||
* - Running Goertzel filter for tone detection
|
||||
* - First-order IIR bandpass filter (HP + LP)
|
||||
*
|
||||
* Morse timing (paris method):
|
||||
* Dit = 1 unit
|
||||
* Dash = 3 units
|
||||
* Intra-char gap = 1 unit
|
||||
* Inter-char gap = 3 units
|
||||
* Word gap = 7 units
|
||||
* Algorithm flow:
|
||||
* Audio buffer → Resample to 4 kHz → High-pass filter (200 Hz) →
|
||||
* Low-pass filter (1200 Hz) → Pitch detection (DFT, 200-1200 Hz) →
|
||||
* Running Goertzel at detected pitch → Adaptive threshold →
|
||||
* Signal interval timing → Speed estimation → Morse character lookup
|
||||
*/
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000,
|
||||
val cwToneFreq: Float = 700f,
|
||||
val filterWidth: Float = 200f
|
||||
cwToneFreq: Float = -1f, // -1 = auto-detect
|
||||
minFreq: Float = 200f,
|
||||
maxFreq: Float = 1200f
|
||||
) {
|
||||
// Filter coefficients (pre-computed)
|
||||
private val firCoeffs = CwDsp.bandpassFir(
|
||||
lowCutoff = ((cwToneFreq - filterWidth / 2) / sampleRate).toDouble(),
|
||||
highCutoff = ((cwToneFreq + filterWidth / 2) / sampleRate).toDouble(),
|
||||
taps = 127
|
||||
)
|
||||
companion object {
|
||||
private val MORSE_TABLE = mapOf(
|
||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||
"0001001" to '$', "011010" to '@'
|
||||
)
|
||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||
|
||||
// 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 const val BASE_SAMPLE_RATE = 4000f
|
||||
private const val PITCH_DETECT_INTERVAL = 100 // frames between pitch scans
|
||||
}
|
||||
|
||||
// Output flows
|
||||
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. */
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
// DSP components
|
||||
private val resampler = CwResampler(sampleRate.toFloat(), BASE_SAMPLE_RATE)
|
||||
private val hpFilter = CwFilter()
|
||||
private val lpFilter = CwFilter()
|
||||
private val pitchDetector = CwPitchDetector(BASE_SAMPLE_RATE, minFreq, maxFreq)
|
||||
private val goertzel = CwGoertzel()
|
||||
|
||||
// Decoder state
|
||||
private var decodedText = StringBuilder()
|
||||
private var currentLetter = StringBuilder()
|
||||
private var isSignal = false
|
||||
private var signalOnSamples = 0
|
||||
private var signalOffSamples = 0
|
||||
private var pitchEstimate = if (cwToneFreq > 0f) cwToneFreq else -1f
|
||||
private var pitchConfidenceCounter = 0
|
||||
private var noiseFloor = 0.0f
|
||||
private var signalPeak = 0.0f
|
||||
private var speedEstimate = 20f // initial guess: 20 WPM
|
||||
private var isPitchLocked = cwToneFreq > 0f
|
||||
|
||||
init {
|
||||
if (isPitchLocked) {
|
||||
_estimatedPitch.value = cwToneFreq
|
||||
}
|
||||
}
|
||||
|
||||
// Interval history for speed estimation
|
||||
private val intervalHistory = mutableListOf<Int>() // lengths of dits (type 0 only)
|
||||
|
||||
// Keep track of last processed sample for the goertzel filter
|
||||
private var goertzelSampleCount = 0
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Bandpass filter around CW tone
|
||||
val filtered = CwDsp.applyFir(buffer, firCoeffs)
|
||||
// 1. Resample to 4 kHz base rate
|
||||
val resampled = resampler.process(buffer)
|
||||
|
||||
// 2. Envelope detection
|
||||
val env = CwDsp.envelope(filtered, 0.1f)
|
||||
for (sample in resampled) {
|
||||
// 2. Bandpass filter chain: 200 Hz HP → 1200 Hz LP
|
||||
val hp = hpFilter.highPass(sample, 200f, BASE_SAMPLE_RATE)
|
||||
val filtered = lpFilter.lowPass(hp, 1200f, BASE_SAMPLE_RATE)
|
||||
val absVal = abs(filtered)
|
||||
|
||||
// 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++
|
||||
// 3. Update noise floor and signal peak (running statistics)
|
||||
noiseFloor = 0.999f * noiseFloor + 0.001f * absVal
|
||||
if (absVal > signalPeak) {
|
||||
signalPeak = absVal
|
||||
} else {
|
||||
// Signal OFF
|
||||
if (isSignalPresent) {
|
||||
// Falling edge — end of tone
|
||||
processTone(signalOnTime)
|
||||
signalOnTime = 0
|
||||
isSignalPresent = false
|
||||
signalPeak = 0.999f * signalPeak
|
||||
}
|
||||
|
||||
// 4. Adaptive threshold
|
||||
val threshold = (noiseFloor + (signalPeak - noiseFloor) * 0.3f)
|
||||
_signalStrength.value = if (signalPeak > 0f && threshold > 0f) {
|
||||
((signalPeak - threshold) / signalPeak).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// 5. Run Goertzel filter if pitch is locked
|
||||
if (isPitchLocked && pitchEstimate > 0f) {
|
||||
goertzel.process(filtered)
|
||||
goertzelSampleCount++
|
||||
}
|
||||
|
||||
// 6. Signal detection with adaptive threshold
|
||||
if (absVal > threshold) {
|
||||
if (!isSignal) {
|
||||
// Rising edge — process the silence gap that just ended
|
||||
if (signalOffSamples > 0) {
|
||||
processGap(signalOffSamples)
|
||||
}
|
||||
signalOffSamples = 0
|
||||
isSignal = true
|
||||
}
|
||||
signalOnSamples++
|
||||
} else {
|
||||
if (isSignal) {
|
||||
// Falling edge — process the tone that just ended
|
||||
processTone(signalOnSamples)
|
||||
signalOnSamples = 0
|
||||
isSignal = false
|
||||
}
|
||||
signalOffSamples++
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Periodic pitch detection (every ~100 frames)
|
||||
if (!isPitchLocked) {
|
||||
pitchConfidenceCounter++
|
||||
if (pitchConfidenceCounter >= PITCH_DETECT_INTERVAL) {
|
||||
pitchConfidenceCounter = 0
|
||||
val pitch = pitchDetector.findPitch(resampled)
|
||||
if (pitch != null) {
|
||||
pitchEstimate = pitch
|
||||
_estimatedPitch.value = pitch
|
||||
isPitchLocked = true
|
||||
goertzel.init(BASE_SAMPLE_RATE, pitch)
|
||||
}
|
||||
signalOffTime++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,72 +184,99 @@ class CwDecoder(
|
||||
_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())
|
||||
}
|
||||
private fun processTone(samples: Int) {
|
||||
val dotDuration = samplesForDot()
|
||||
if (dotDuration <= 0) return
|
||||
|
||||
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
|
||||
val ratio = samples.toFloat() / dotDuration
|
||||
|
||||
if (ratio < 1.5f) {
|
||||
currentLetter.append('0') // 0 = dot
|
||||
// Track dit lengths for speed estimation
|
||||
intervalHistory.add(samples)
|
||||
if (intervalHistory.size > 20) intervalHistory.removeAt(0)
|
||||
} else if (ratio < 5.0f) {
|
||||
currentLetter.append('1') // 1 = dash
|
||||
}
|
||||
// else: ignore very long tones (likely noise)
|
||||
// else: ignore very long tones (likely noise/interference)
|
||||
|
||||
// Update speed estimate from recent dits
|
||||
updateSpeedEstimate()
|
||||
}
|
||||
|
||||
private fun processSilence(duration: Int) {
|
||||
if (currentSymbol.isNotEmpty()) {
|
||||
// Inter-character gap (3+ units) — decode accumulated symbol
|
||||
val gapRatio = duration.toFloat() / (avgDitDuration.coerceAtLeast(1f))
|
||||
private fun processGap(samples: Int) {
|
||||
val dotDuration = samplesForDot()
|
||||
if (dotDuration <= 0) return
|
||||
|
||||
val gapRatio = samples.toFloat() / dotDuration
|
||||
|
||||
if (currentLetter.isNotEmpty()) {
|
||||
// Inter-character gap (3+ dot durations)
|
||||
if (gapRatio >= 2.5f) {
|
||||
val char = morseToChar(currentSymbol.toString())
|
||||
val char = morseToChar(currentLetter.toString())
|
||||
if (char != null) {
|
||||
decodedText.append(char)
|
||||
}
|
||||
currentSymbol.clear()
|
||||
currentLetter.clear()
|
||||
|
||||
// Word gap (7+ units)
|
||||
// Word gap (7+ dot durations)
|
||||
if (gapRatio >= 7f) {
|
||||
decodedText.append(' ')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Word gap (7+ dot durations, no letter in progress)
|
||||
if (gapRatio >= 7f) {
|
||||
decodedText.append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun samplesForDot(): Int {
|
||||
// Convert WPM to samples at 4 kHz base rate
|
||||
// Using standard formula: dot = 60/(50*WPM) seconds
|
||||
return ((BASE_SAMPLE_RATE * 60.0 / (50.0 * speedEstimate)).toInt()).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun updateSpeedEstimate() {
|
||||
if (intervalHistory.size < 3) return
|
||||
|
||||
// Use median of recent dit lengths for speed estimation
|
||||
val sorted = intervalHistory.sorted()
|
||||
val median = sorted[sorted.size / 2].toFloat()
|
||||
|
||||
if (median > 0f) {
|
||||
val newSpeed = 60.0f / (50.0f * median / BASE_SAMPLE_RATE)
|
||||
if (newSpeed in 5f..55f) {
|
||||
// Smooth speed update (70% old, 30% new)
|
||||
speedEstimate = speedEstimate * 0.7f + newSpeed * 0.3f
|
||||
_estimatedSpeed.value = speedEstimate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
isSignalPresent = false
|
||||
signalOnTime = 0
|
||||
signalOffTime = 0
|
||||
avgDitDuration = 0f
|
||||
isSignal = false
|
||||
signalOnSamples = 0
|
||||
signalOffSamples = 0
|
||||
decodedText.clear()
|
||||
currentSymbol.clear()
|
||||
currentLetter.clear()
|
||||
intervalHistory.clear()
|
||||
noiseFloor = 0.0f
|
||||
signalPeak = 0.0f
|
||||
speedEstimate = 20f
|
||||
if (!isPitchLocked) {
|
||||
pitchEstimate = -1f
|
||||
pitchConfidenceCounter = 0
|
||||
}
|
||||
goertzelSampleCount = 0
|
||||
hpFilter.reset()
|
||||
lpFilter.reset()
|
||||
resampler.reset()
|
||||
goertzel.reset()
|
||||
_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]
|
||||
_estimatedPitch.value = if (isPitchLocked) pitchEstimate else null
|
||||
_estimatedSpeed.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* First-order IIR filters.
|
||||
* Ported from ggmorse/src/filter.h
|
||||
*/
|
||||
internal class CwFilter {
|
||||
private var z1 = 0f
|
||||
|
||||
companion object {
|
||||
private const val PI_F = 3.141592653589793f
|
||||
}
|
||||
|
||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 = alpha * (z1 + sample - z1)
|
||||
return sample - z1
|
||||
}
|
||||
|
||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 += alpha * (sample - z1)
|
||||
return z1
|
||||
}
|
||||
|
||||
fun reset() { z1 = 0f }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Running Goertzel filter for CW tone detection.
|
||||
* Tracks a specific frequency over time with a sliding window.
|
||||
* Ported from ggmorse/src/goertzel.h
|
||||
*/
|
||||
internal class CwGoertzel {
|
||||
private var s1 = 0.0
|
||||
private var s2 = 0.0
|
||||
private var coeff = 0.0
|
||||
|
||||
fun init(sampleRate: Float, targetFreq: Float) {
|
||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||
coeff = 2.0 * cos(omega)
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
|
||||
fun process(sample: Float) {
|
||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
|
||||
fun getPower(): Float {
|
||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Simple linear resampler.
|
||||
* Downsamples from input sample rate to output sample rate.
|
||||
* Ported from ggmorse/src/resampler.h
|
||||
*/
|
||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||
private val ratio = inputRate / outputRate
|
||||
private var lastSample = 0f
|
||||
|
||||
fun process(input: FloatArray): FloatArray {
|
||||
if (ratio <= 0f || input.isEmpty()) return input
|
||||
val outputLen = (input.size / ratio).toInt() + 1
|
||||
val output = FloatArray(outputLen)
|
||||
var idx = 0f
|
||||
for (i in output.indices) {
|
||||
val intIdx = idx.toInt()
|
||||
val frac = idx - intIdx
|
||||
if (intIdx + 1 < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||
} else if (intIdx < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||
} else {
|
||||
output[i] = lastSample
|
||||
}
|
||||
idx += ratio
|
||||
}
|
||||
lastSample = input.lastOrNull() ?: lastSample
|
||||
return output
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSample = 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||
*/
|
||||
internal class CwPitchDetector(
|
||||
private val sampleRate: Float,
|
||||
private val minFreq: Float = 200f,
|
||||
private val maxFreq: Float = 1200f,
|
||||
private val stepHz: Float = 10f
|
||||
) {
|
||||
/**
|
||||
* Find the dominant pitch frequency in the buffer.
|
||||
* Returns null if no significant pitch found.
|
||||
*/
|
||||
fun findPitch(buffer: FloatArray): Float? {
|
||||
if (buffer.isEmpty()) return null
|
||||
var bestFreq = 0f
|
||||
var bestPower = 0f
|
||||
var freq = minFreq
|
||||
while (freq <= maxFreq) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||
for (i in buffer.indices) {
|
||||
real += buffer[i] * cos(omega * i)
|
||||
imag += buffer[i] * -sin(omega * i)
|
||||
}
|
||||
val power = (real * real + imag * imag).toFloat()
|
||||
if (power > bestPower) {
|
||||
bestPower = power
|
||||
bestFreq = freq
|
||||
}
|
||||
freq += stepHz
|
||||
}
|
||||
return if (bestPower > 0.001f) bestFreq else null
|
||||
}
|
||||
}
|
||||
@@ -28,120 +28,170 @@ class CwDecoderTest {
|
||||
|
||||
@Test
|
||||
fun morseToChar_basicLetters() {
|
||||
assertEquals('A', CwDecoder.morseToChar(".-"))
|
||||
assertEquals('B', CwDecoder.morseToChar("-..."))
|
||||
assertEquals('S', CwDecoder.morseToChar("..."))
|
||||
assertEquals('O', CwDecoder.morseToChar("---"))
|
||||
assertEquals('C', CwDecoder.morseToChar("-.-."))
|
||||
assertEquals('A', CwDecoder.morseToChar("01"))
|
||||
assertEquals('B', CwDecoder.morseToChar("1000"))
|
||||
assertEquals('S', CwDecoder.morseToChar("000"))
|
||||
assertEquals('O', CwDecoder.morseToChar("111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_numbers() {
|
||||
assertEquals('1', CwDecoder.morseToChar(".----"))
|
||||
assertEquals('5', CwDecoder.morseToChar("....."))
|
||||
assertEquals('0', CwDecoder.morseToChar("-----"))
|
||||
assertEquals('1', CwDecoder.morseToChar("01111"))
|
||||
assertEquals('5', CwDecoder.morseToChar("00000"))
|
||||
assertEquals('0', CwDecoder.morseToChar("11111"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_unknown_returnsNull() {
|
||||
assertNull(CwDecoder.morseToChar("......."))
|
||||
assertNull(CwDecoder.morseToChar(""))
|
||||
assertNull(CwDecoder.morseToChar(".-.-.-.-"))
|
||||
assertNull(CwDecoder.morseToChar("01-01"))
|
||||
}
|
||||
|
||||
// --- Resampler ---
|
||||
|
||||
@Test
|
||||
fun resampler_downsampleReducesSize() {
|
||||
val resampler = CwResampler(8000f, 4000f)
|
||||
val input = FloatArray(8000) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||
val output = resampler.process(input)
|
||||
assertTrue("Output size ${output.size} should be ~4000", output.size in 3800..4200)
|
||||
}
|
||||
|
||||
@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)
|
||||
fun resampler_emptyInput_returnsEmpty() {
|
||||
val resampler = CwResampler(8000f, 4000f)
|
||||
val output = resampler.process(FloatArray(0))
|
||||
assertTrue(output.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bandpassFir_oddTaps_forcesOdd() {
|
||||
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 100)
|
||||
assertEquals(101, coeffs.size) // forces odd
|
||||
fun resampler_sameRate_returnsSameSize() {
|
||||
val resampler = CwResampler(4000f, 4000f)
|
||||
val input = FloatArray(100) { it.toFloat() }
|
||||
val output = resampler.process(input)
|
||||
assertTrue("Output size should be ~100", output.size in 95..105)
|
||||
}
|
||||
|
||||
@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)
|
||||
fun resampler_resetClearsState() {
|
||||
val resampler = CwResampler(8000f, 4000f)
|
||||
val input = FloatArray(100) { 1f }
|
||||
resampler.process(input)
|
||||
resampler.reset()
|
||||
// Should not crash
|
||||
resampler.process(FloatArray(100) { 0f })
|
||||
}
|
||||
|
||||
// --- Filter ---
|
||||
|
||||
@Test
|
||||
fun filter_highPass_doesNotCrash() {
|
||||
val filter = CwFilter()
|
||||
val sampleRate = 4000f
|
||||
// Test that the filter runs without crashing and produces finite values
|
||||
val output = FloatArray(100) { filter.highPass(1.0f, 200f, sampleRate) }
|
||||
output.forEach { assertFalse("Output should be finite: $it", it.isNaN() || it.isInfinite()) }
|
||||
}
|
||||
|
||||
@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)
|
||||
fun filter_lowPass_smoothsSignal() {
|
||||
val filter = CwFilter()
|
||||
val sampleRate = 4000f
|
||||
// High frequency noise
|
||||
val output = FloatArray(100) { filter.lowPass((sin(2.0 * PI * 1000.0 * it / sampleRate)).toFloat(), 500f, sampleRate) }
|
||||
val maxVal = output.maxOrNull() ?: 1f
|
||||
assertTrue("High freq should be attenuated, max=$maxVal", maxVal < 0.8f)
|
||||
}
|
||||
|
||||
@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)
|
||||
fun filter_reset() {
|
||||
val filter = CwFilter()
|
||||
filter.highPass(1f, 200f, 4000f)
|
||||
filter.reset()
|
||||
// Should not crash
|
||||
assertEquals(0f, filter.highPass(0f, 200f, 4000f), 0.001f)
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
// --- Goertzel ---
|
||||
|
||||
@Test
|
||||
fun goertzel_detectsPresentTone() {
|
||||
val sampleRate = 8000
|
||||
val sampleRate = 4000f
|
||||
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)
|
||||
val goertzel = CwGoertzel()
|
||||
goertzel.init(sampleRate, targetFreq)
|
||||
// Generate 700 Hz tone
|
||||
for (i in 0 until sampleRate.toInt()) {
|
||||
goertzel.process((sin(2.0 * PI * targetFreq * i / sampleRate)).toFloat())
|
||||
}
|
||||
val power = goertzel.getPower()
|
||||
assertTrue("Goertzel should detect present tone, got $power", power > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goertzel_rejectsAbsentTone() {
|
||||
val sampleRate = 8000
|
||||
val sampleRate = 4000f
|
||||
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)
|
||||
val goertzel = CwGoertzel()
|
||||
goertzel.init(sampleRate, targetFreq)
|
||||
// Generate 2000 Hz tone (no match)
|
||||
for (i in 0 until sampleRate.toInt()) {
|
||||
goertzel.process((sin(2.0 * PI * 2000f * i / sampleRate)).toFloat())
|
||||
}
|
||||
val power = goertzel.getPower()
|
||||
assertTrue("Goertzel should reject absent tone, got $power", power < 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goertzel_detectsToneInNoise() {
|
||||
val sampleRate = 8000
|
||||
fun goertzel_reset() {
|
||||
val goertzel = CwGoertzel()
|
||||
goertzel.init(4000f, 700f)
|
||||
goertzel.process(1f)
|
||||
goertzel.reset()
|
||||
assertEquals(0f, goertzel.getPower(), 0.001f)
|
||||
}
|
||||
|
||||
// --- Pitch detector ---
|
||||
|
||||
@Test
|
||||
fun pitchDetector_findsCorrectFrequency() {
|
||||
val sampleRate = 4000f
|
||||
val detector = CwPitchDetector(sampleRate, 200f, 1200f, 10f)
|
||||
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 buffer = FloatArray(sampleRate.toInt()) { (sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() }
|
||||
val pitch = detector.findPitch(buffer)
|
||||
assertNotNull("Pitch should be detected", pitch)
|
||||
if (pitch != null) {
|
||||
assertTrue("Detected pitch $pitch should be close to 700 Hz", pitch in 680f..720f)
|
||||
}
|
||||
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
|
||||
assertTrue("Goertzel should detect tone in noise, got $power", power > 0.1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pitchDetector_findsDifferentFrequency() {
|
||||
val sampleRate = 4000f
|
||||
val detector = CwPitchDetector(sampleRate, 200f, 1200f, 10f)
|
||||
val targetFreq = 500f
|
||||
val buffer = FloatArray(sampleRate.toInt()) { (sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() }
|
||||
val pitch = detector.findPitch(buffer)
|
||||
assertNotNull("Pitch should be detected", pitch)
|
||||
if (pitch != null) {
|
||||
assertTrue("Detected pitch $pitch should be close to 500 Hz", pitch in 480f..520f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pitchDetector_returnsNullForSilence() {
|
||||
val detector = CwPitchDetector(4000f)
|
||||
val buffer = FloatArray(4000) { 0f }
|
||||
val pitch = detector.findPitch(buffer)
|
||||
assertNull("Pitch should be null for silence", pitch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pitchDetector_emptyBuffer() {
|
||||
val detector = CwPitchDetector(4000f)
|
||||
assertNull(detector.findPitch(FloatArray(0)))
|
||||
}
|
||||
|
||||
// --- Decoder state ---
|
||||
@@ -151,30 +201,29 @@ class CwDecoderTest {
|
||||
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)
|
||||
assertNull(decoder.estimatedPitch.value)
|
||||
assertNull(decoder.estimatedSpeed.value)
|
||||
}
|
||||
|
||||
@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)
|
||||
val decoder = CwDecoder(sampleRate = 11025, cwToneFreq = 600f)
|
||||
assertEquals(11025, decoder.sampleRate)
|
||||
assertEquals(600f, decoder.cwToneFreq, 0.001f)
|
||||
assertEquals(100f, decoder.filterWidth, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetDecoder_clearsState() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.processBuffer(FloatArray(128) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||
decoder.resetDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -190,43 +239,29 @@ class CwDecoderTest {
|
||||
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
|
||||
fun processBuffer_withFixedPitch_doesNotCrash() {
|
||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||
val buf = FloatArray(512) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||
decoder.processBuffer(buf)
|
||||
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
|
||||
fun cwDecoder_withFixedPitchBypassesAutoDetect() {
|
||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 600f)
|
||||
assertEquals(600f, decoder.estimatedPitch.value)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@Test
|
||||
fun resetDecoder_afterFixedPitch() {
|
||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||
decoder.resetDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
// Pitch should still be locked at 700
|
||||
assertEquals(700f, decoder.estimatedPitch.value)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user