chore: remove .hermes from git tracking, add to gitignore
This commit is contained in:
1 parent
7e90071c4d
commit
27832c43f2
6 files changed
+3
-2505
No files matched your search
@@ -1,6 +1,9 @@
|
||||
# Built application files
|
||||
*.ap_
|
||||
|
||||
# Hermes AI plans and metadata
|
||||
.hermes/
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
|
||||
@@ -1,781 +0,0 @@
|
||||
# CW Morse Code Decoder — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a built-in Morse Code (CW) decoder to the Look4Sat linear satellite transceiver interface. Captures audio from the microphone, decodes CW in real-time, and displays the decoded text inline without blocking existing features.
|
||||
|
||||
**Architecture:** Domain layer DSP (bandpass filter + envelope detection + timing logic) mirrors the existing `SstvDecoder` pattern. State managed in `RadarState` / `RadarViewModel`. UI is a compact collapsible panel inside the `TransceiverItem` expanded card, below the Doppler calculator, using the same `IAudioCapture` abstraction for microphone access.
|
||||
|
||||
**Tech Stack:** Kotlin, Jetpack Compose, Android AudioRecord (via existing `IAudioCapture`), pure Kotlin DSP (no NDK), `kotlinx.coroutines.flow`.
|
||||
|
||||
---
|
||||
|
||||
## Current Context
|
||||
|
||||
The app already has:
|
||||
- **`IAudioCapture`** (`core/domain/.../usecase/IAudioCapture.kt`) — platform abstraction for microphone audio capture, emits `Flow<FloatArray>` at configurable sample rate
|
||||
- **`SstvDecoder`** (`core/domain/.../sstv/SstvDecoder.kt`) — full audio→image decoder for SSTV, the architectural pattern to follow
|
||||
- **`SstvDsp`** (`core/domain/.../sstv/SstvDsp.kt`) — DSP utilities (FFT, window functions, filters) — reusable for CW
|
||||
- **`RadarViewModel`** — manages SSTV lifecycle (init decoder, start/stop recording, handle permission)
|
||||
- **`TransceiversPage.kt`** — expanded transceiver card with Doppler calculator already added
|
||||
- **`RadarState.kt`** — `SstvSubState` pattern to follow for CW substate
|
||||
|
||||
**CW decoding pipeline (audio → text):**
|
||||
```
|
||||
Audio buffer → Bandpass filter (~600-800 Hz) → Envelope detection → Threshold →
|
||||
Timing (dit/dash/symbol/word gaps) → Morse character lookup → Live text output
|
||||
```
|
||||
|
||||
**Key frequencies:**
|
||||
- CW tone: typically 600-800 Hz (user-selectable)
|
||||
- Sampling rate: 8000 Hz (reuse from SSTV, adequate for ~1 kHz bandwidth)
|
||||
- Dit timing: 30-60 ms at typical 20-30 WPM (auto-baud rate detection)
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: Create CW decoder DSP (domain layer)
|
||||
|
||||
**Objective:** Implement the core CW signal processing: bandpass filter, envelope detection, and timing logic to convert audio samples to dit/dash symbols.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt`
|
||||
- Create: `core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt`
|
||||
- Modify: `core/domain/build.gradle.kts` (no changes needed — pure Kotlin)
|
||||
|
||||
**Step 1: Create `CwDsp.kt` — DSP utilities for CW decoding**
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.log10
|
||||
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 (median of envelope). */
|
||||
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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create `CwDecoder.kt` — Morse character table + timing logic**
|
||||
|
||||
```kotlin
|
||||
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.
|
||||
*
|
||||
* 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 filterState = FloatArray(0)
|
||||
private var envelopeState = 0f
|
||||
private var previousEnvelope = 0f
|
||||
private var isSignalPresent = false
|
||||
private var signalOnTime = 0 // samples since signal started
|
||||
private var signalOffTime = 0 // samples since signal ended
|
||||
private var decodedText = StringBuilder()
|
||||
private var currentSymbol = StringBuilder()
|
||||
|
||||
private val _decodedTextFlow = MutableStateFlow("")
|
||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||
|
||||
/** Process a buffer of audio samples. */
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Bandpass filter
|
||||
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.5f
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
|
||||
_decodedTextFlow.value = decodedText.toString()
|
||||
}
|
||||
|
||||
private fun processTone(duration: Int) {
|
||||
val unit = estimateUnit(duration)
|
||||
if (unit == 0) return
|
||||
|
||||
val ratio = duration.toFloat() / unit
|
||||
if (ratio < 1.5f) {
|
||||
currentSymbol.append('.') // Dit
|
||||
} else if (ratio < 5f) {
|
||||
currentSymbol.append('-') // Dash
|
||||
}
|
||||
}
|
||||
|
||||
private fun processSilence(duration: Int) {
|
||||
// If we have accumulated symbol characters, it's an inter-char gap
|
||||
if (currentSymbol.isNotEmpty()) {
|
||||
val char = morseToChar(currentSymbol.toString())
|
||||
if (char != null) {
|
||||
decodedText.append(char)
|
||||
}
|
||||
currentSymbol.clear()
|
||||
} else {
|
||||
// Word gap (7+ units)
|
||||
val unit = estimateUnitFromSilence(duration)
|
||||
val ratio = if (unit > 0) duration.toFloat() / unit else 0f
|
||||
if (ratio >= 7f) {
|
||||
decodedText.append(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Estimate the timing unit based on recent dits. */
|
||||
private fun estimateUnit(duration: Int): Int {
|
||||
// For first detection, estimate based on typical 20 WPM = 60ms dit
|
||||
// 8000 Hz * 0.06s = 480 samples
|
||||
return if (duration < 800) sampleRate / 20 else sampleRate / 15
|
||||
}
|
||||
|
||||
private fun estimateUnitFromSilence(duration: Int): Int {
|
||||
return sampleRate / 20
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
isSignalPresent = false
|
||||
signalOnTime = 0
|
||||
signalOffTime = 0
|
||||
decodedText.clear()
|
||||
currentSymbol.clear()
|
||||
_decodedTextFlow.value = ""
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify compilation**
|
||||
|
||||
Run: `cd /mnt/e/look4sat-work && ./gradlew :core:domain:compileKotlin --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/
|
||||
git commit -m "feat(cw): add CW decoder with DSP and Morse timing logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add CW state to RadarState + RadarAction
|
||||
|
||||
**Objective:** Define the CW substate and action types so the ViewModel and UI can communicate.
|
||||
|
||||
**Files:**
|
||||
- Modify: `feature/radar/src/main/java/.../RadarState.kt`
|
||||
|
||||
**Step 1: Add CW substate and actions**
|
||||
|
||||
Add to `RadarState.kt` after the SSTV section:
|
||||
|
||||
```kotlin
|
||||
// --- CW Decoder ---
|
||||
|
||||
enum class CwStatus { Idle, Listening }
|
||||
|
||||
data class CwSubState(
|
||||
val status: CwStatus = CwStatus.Idle,
|
||||
val hasPermission: Boolean = false,
|
||||
val decodedText: String = "",
|
||||
val cwToneFreq: Float = 700f,
|
||||
val isExpanded: Boolean = false,
|
||||
val signalStrength: Float = 0f
|
||||
)
|
||||
```
|
||||
|
||||
Add to `RadarState` data class after `sstv`:
|
||||
|
||||
```kotlin
|
||||
val cw: CwSubState = CwSubState()
|
||||
```
|
||||
|
||||
Add to `RadarAction` sealed interface:
|
||||
|
||||
```kotlin
|
||||
// CW actions
|
||||
data object CwStartListening : RadarAction
|
||||
data object CwStopListening : RadarAction
|
||||
data object CwReset : RadarAction
|
||||
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add feature/radar/src/main/java/.../RadarState.kt
|
||||
git commit -m "feat(cw): add CW decoder state and actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire CW decoder into RadarViewModel
|
||||
|
||||
**Objective:** Initialize the CW decoder from audio capture, process buffers, and update state.
|
||||
|
||||
**Files:**
|
||||
- Modify: `feature/radar/src/main/java/.../RadarViewModel.kt`
|
||||
|
||||
**Step 1: Add CW decoder member variables**
|
||||
|
||||
```kotlin
|
||||
private var cwDecoder: CwDecoder? = null
|
||||
private var cwListeningJob: Job? = null
|
||||
```
|
||||
|
||||
**Step 2: Add CW permission handling**
|
||||
|
||||
In the `RadarAction.SstvPermissionResult` block, also grant CW permission:
|
||||
|
||||
```kotlin
|
||||
is RadarAction.CwPermissionResult -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
|
||||
if (action.granted) initCwDecoder()
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add CW start/stop/reset handlers**
|
||||
|
||||
```kotlin
|
||||
private fun initCwDecoder() {
|
||||
if (cwDecoder == null) {
|
||||
cwDecoder = CwDecoder(
|
||||
sampleRate = audioCapture.sampleRate,
|
||||
cwToneFreq = _uiState.value.cw.cwToneFreq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCwListening() {
|
||||
val decoder = cwDecoder ?: return
|
||||
decoder.resetDecoder()
|
||||
// Collect decoded text flow
|
||||
cwListeningJob = viewModelScope.launch {
|
||||
decoder.decodedTextFlow.collect { text ->
|
||||
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
|
||||
}
|
||||
}
|
||||
// Start audio capture
|
||||
cwListeningJob = viewModelScope.launch {
|
||||
audioCapture.audioFlow().collect { buffer ->
|
||||
decoder.processBuffer(buffer)
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
|
||||
}
|
||||
|
||||
private fun stopCwListening() {
|
||||
cwListeningJob?.cancel()
|
||||
cwListeningJob = null
|
||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add CW action dispatch**
|
||||
|
||||
In the `when (action)` block, add:
|
||||
|
||||
```kotlin
|
||||
is RadarAction.CwStartListening -> {
|
||||
if (!_uiState.value.cw.hasPermission) {
|
||||
requestMicPermission()
|
||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
|
||||
} else {
|
||||
startCwListening()
|
||||
}
|
||||
}
|
||||
RadarAction.CwStopListening -> stopCwListening()
|
||||
RadarAction.CwReset -> {
|
||||
cwDecoder?.resetDecoder()
|
||||
_uiState.update { it.copy(cw = it.cw.copy(decodedText = "")) }
|
||||
}
|
||||
is RadarAction.CwSetToneFreq -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(cwToneFreq = action.freq)) }
|
||||
cwDecoder = CwDecoder(sampleRate = audioCapture.sampleRate, cwToneFreq = action.freq)
|
||||
}
|
||||
is RadarAction.CwToggleExpanded -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.CwPermissionResult -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
|
||||
if (action.granted) initCwDecoder()
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add feature/radar/src/main/java/.../RadarViewModel.kt
|
||||
git commit -m "feat(cw): wire CW decoder into RadarViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add CW UI to TransceiversPage
|
||||
|
||||
**Objective:** Add a compact, collapsible CW decoder panel to the transceiver expanded card, below the Doppler calculator. Does not block other features.
|
||||
|
||||
**Files:**
|
||||
- Modify: `feature/radar/src/main/java/.../TransceiversPage.kt`
|
||||
- Modify: `core/presentation/src/main/res/values/strings.xml`
|
||||
|
||||
**Step 1: Add string resources**
|
||||
|
||||
```xml
|
||||
<string name="radar_cw_decoder">CW Decoder</string>
|
||||
<string name="radar_cw_start">Start Listening</string>
|
||||
<string name="radar_cw_stop">Stop</string>
|
||||
<string name="radar_cw_reset">Clear</string>
|
||||
<string name="radar_cw_tone">Tone (Hz)</string>
|
||||
<string name="radar_cw_expand">CW Decoder</string>
|
||||
```
|
||||
|
||||
**Step 2: Add CW decoder composable**
|
||||
|
||||
Add a new composable `CwDecoderPanel` at the end of `TransceiversPage.kt`:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun CwDecoderPanel(
|
||||
cw: CwSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Header row: expand/collapse toggle
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAction(RadarAction.CwToggleExpanded(!cw.isExpanded)) },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_cw_decoder),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = if (cw.isExpanded) R.drawable.ic_arrow_up else R.drawable.ic_arrow_down),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = cw.isExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Control buttons row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (cw.status == CwStatus.Idle) {
|
||||
Button(onClick = { onAction(RadarAction.CwStartListening) }) {
|
||||
Text(stringResource(R.string.radar_cw_start))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { onAction(RadarAction.CwStopListening) }) {
|
||||
Text(stringResource(R.string.radar_cw_stop))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { onAction(RadarAction.CwReset) }) {
|
||||
Text(stringResource(R.string.radar_cw_reset))
|
||||
}
|
||||
}
|
||||
|
||||
// Tone frequency field
|
||||
OutlinedTextField(
|
||||
value = cw.cwToneFreq.toInt().toString(),
|
||||
onValueChange = { value ->
|
||||
value.toIntOrNull()?.let { freq ->
|
||||
onAction(RadarAction.CwSetToneFreq(freq.toFloat()))
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.radar_cw_tone)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Decoded text output
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Decoded:",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = cw.decodedText.ifEmpty() { "Waiting for CW signal..." },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Wire into ExpandedRadioControl**
|
||||
|
||||
In `ExpandedRadioControl`, after the Doppler calculator and before the control buttons, add:
|
||||
|
||||
```kotlin
|
||||
// CW decoder panel
|
||||
CwDecoderPanel(cw = cw, onAction = onAction)
|
||||
```
|
||||
|
||||
**Step 4: Pass cw state to TransceiverItem and ExpandedRadioControl**
|
||||
|
||||
Add `cw: CwSubState` parameter to `TransceiverItem` and `ExpandedRadioControl`:
|
||||
|
||||
```kotlin
|
||||
// TransceiverItem signature
|
||||
cw: CwSubState,
|
||||
|
||||
// ExpandedRadioControl signature
|
||||
cw: CwSubState,
|
||||
```
|
||||
|
||||
**Step 5: Pass cw from TransceiversPage**
|
||||
|
||||
```kotlin
|
||||
fun TransceiversPage(
|
||||
cw: CwSubState,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Step 6: Wire from RadarScreen**
|
||||
|
||||
```kotlin
|
||||
RadarPage.Transceivers -> TransceiversPage(
|
||||
cw = uiState.cw,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add feature/radar/src/main/java/.../TransceiversPage.kt core/presentation/.../strings.xml
|
||||
git commit -m "feat(cw): add CW decoder UI panel to transceivers page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add CW decoder unit tests
|
||||
|
||||
**Objective:** Test the Morse lookup table, DSP filter, and basic timing logic.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt`
|
||||
|
||||
**Step 1: Write tests**
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class CwDecoderTest {
|
||||
|
||||
@Test
|
||||
fun morseToChar_basicLetters() {
|
||||
assertEquals('A', CwDecoder.morseToChar(".-"))
|
||||
assertEquals('S', CwDecoder.morseToChar("..."))
|
||||
assertEquals('O', CwDecoder.morseToChar("---"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_numbers() {
|
||||
assertEquals('1', CwDecoder.morseToChar(".----"))
|
||||
assertEquals('0', CwDecoder.morseToChar("-----"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun morseToChar_unknown_returnsNull() {
|
||||
assertNull(CwDecoder.morseToChar("....."))
|
||||
}
|
||||
|
||||
@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 applyFir_preservesLength() {
|
||||
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 31)
|
||||
val input = FloatArray(100) { kotlin.math.sin(it * 0.1f) }
|
||||
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 goertzel_detectsPresentTone() {
|
||||
val sampleRate = 8000
|
||||
val targetFreq = 700f
|
||||
// Generate a 700 Hz tone
|
||||
val buffer = FloatArray(sampleRate) { kotlin.math.sin(2 * kotlin.math.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)
|
||||
val buffer = FloatArray(sampleRate) { kotlin.math.sin(2 * kotlin.math.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 resetDecoder_clearsText() {
|
||||
val decoder = CwDecoder()
|
||||
decoder.resetDecoder()
|
||||
assertEquals("", decoder.decodedTextFlow.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
Run: `./gradlew :core:domain:test --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL, all tests pass
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/test/java/.../CwDecoderTest.kt
|
||||
git commit -m "test(cw): add CW decoder unit tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final build verification
|
||||
|
||||
**Objective:** Ensure the full 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: Send APK**
|
||||
|
||||
The APK is at `app/build/outputs/apk/debug/app-debug.apk`. Share with the user for testing.
|
||||
|
||||
---
|
||||
|
||||
## Files likely to change
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `core/domain/src/main/java/.../cw/CwDsp.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwDecoder.kt` | Create |
|
||||
| `core/domain/src/test/java/.../cw/CwDecoderTest.kt` | Create |
|
||||
| `feature/radar/src/main/java/.../RadarState.kt` | Modify (add CwSubState, actions) |
|
||||
| `feature/radar/src/main/java/.../RadarViewModel.kt` | Modify (wire decoder) |
|
||||
| `feature/radar/src/main/java/.../TransceiversPage.kt` | Modify (add CW panel) |
|
||||
| `feature/radar/src/main/java/.../RadarScreen.kt` | Modify (pass cw state) |
|
||||
| `core/presentation/src/main/res/values/strings.xml` | Modify (add CW strings) |
|
||||
|
||||
## Risks / tradeoffs
|
||||
|
||||
- **Audio conflict:** CW decoder uses the same `IAudioCapture` as SSTV. Only one can listen at a time. The ViewModel should stop SSTV when CW starts and vice versa.
|
||||
- **Performance:** FIR filter with 127 taps per audio buffer is lightweight (~1ms per 1024-sample buffer at 8kHz). Pure Kotlin is fast enough.
|
||||
- **Accuracy:** The simple timing-based decoder works well for clean CW signals (~20-30 WPM). Noisy signals or extreme speeds (>40 WPM) will degrade accuracy. The Goertzel tone detector helps suppress false triggers from non-CW signals.
|
||||
- **UI layout:** The CW panel is collapsible by default (controlled by `isExpanded`). It appears below the Doppler calculator and above the radio control buttons, fitting in the existing scrollable area without blocking other features.
|
||||
- **Permission:** `RECORD_AUDIO` permission is already requested for SSTV. The CW decoder can reuse the same permission flow.
|
||||
- **No NDK:** Pure Kotlin DSP is sufficient for the narrow bandwidth of CW (200-300 Hz). No need for FFT-based spectrogram analysis for v1.
|
||||
@@ -1,566 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,876 +0,0 @@
|
||||
# CW Decoder v3 — Spectrogram-based Multi-channel Bayesian Decoder
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the current time-domain CW decoder with a spectrogram-based multi-channel decoder inspired by Morse Expert / CW Skimmer (VE3NEA). Uses FFT waterfall for frequency-agnostic signal detection and Bayesian probability for symbol timing.
|
||||
|
||||
**Architecture:** Pure Kotlin, no external dependencies. Audio → FFT → Spectrogram → Multi-channel peak detection → Per-channel energy envelope → Bayesian timing analysis → Morse character decoding. Multiple channels tracked simultaneously; the best one is selected for output.
|
||||
|
||||
**Tech Stack:** Kotlin, radix-2 FFT (reuse `Complex` from `SstvDsp`), waterfall spectrogram, Bayesian probability framework.
|
||||
|
||||
---
|
||||
|
||||
## Current Context
|
||||
|
||||
The existing v2 decoder (ggmorse port) has these limitations:
|
||||
- **Single-channel**: locks one Goertzel filter to one frequency
|
||||
- **Time-domain only**: signal disappears if frequency drifts
|
||||
- **Hard thresholds**: dit/dash classification uses fixed ratios
|
||||
- **No probability**: every decision is binary
|
||||
|
||||
**Morse Expert / CW Skimmer approach:**
|
||||
- **Spectrogram-based**: FFT creates a 2D frequency×time matrix
|
||||
- **Multi-channel**: all frequencies monitored simultaneously
|
||||
- **Bayesian**: probability-based decisions, not hard thresholds
|
||||
- **Frequency-agnostic**: drift just moves energy between bins, never lost
|
||||
|
||||
**Key insight from VE3NEA (CW Skimmer author):**
|
||||
> "Instead of making a hard decision at every input sample whether the signal is present or not, compute the probability that the signal is present. Combine that probability with other probabilities using the Bayes formula and pass the new probabilities to the subsequent stages, all the way to the word recognition unit."
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Audio buffer (128 samples @ 8 kHz)
|
||||
│
|
||||
▼
|
||||
Hanning window
|
||||
│
|
||||
▼
|
||||
FFT (256-point radix-2)
|
||||
│
|
||||
▼
|
||||
Spectrogram update (frequency bins × time columns)
|
||||
│
|
||||
▼
|
||||
Peak detection (find active frequency bins)
|
||||
│
|
||||
▼
|
||||
For each active channel:
|
||||
├─ Energy envelope extraction (time series at that bin)
|
||||
├─ Adaptive noise floor estimation
|
||||
├─ Signal probability computation (Bayesian)
|
||||
└─ Timing analysis → symbol sequence → Morse character
|
||||
│
|
||||
▼
|
||||
Select best channel → Output decoded text
|
||||
```
|
||||
|
||||
**FFT parameters:**
|
||||
- FFT size: 256 points (windowed)
|
||||
- Hop size: 64 samples (75% overlap)
|
||||
- Frequency resolution: 8000/256 = 31.25 Hz per bin
|
||||
- Time resolution: 64/8000 = 8 ms per column
|
||||
- Analyzed range: bins 6-38 (187-1187 Hz, covers 200-1200 Hz CW range)
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: Create FFT implementation
|
||||
|
||||
**Objective:** Write a radix-2 FFT that works on real audio data, producing magnitude spectrum.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/.../cw/CwFFT.kt`
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Radix-2 FFT for real-valued input.
|
||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||
*/
|
||||
internal class CwFFT(private val n: Int) {
|
||||
init {
|
||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||
}
|
||||
|
||||
private val cosTable = FloatArray(n / 2)
|
||||
private val sinTable = FloatArray(n / 2)
|
||||
|
||||
init {
|
||||
for (i in 0 until n / 2) {
|
||||
val angle = -2.0 * kotlin.math.PI * i / n
|
||||
cosTable[i] = kotlin.math.cos(angle).toFloat()
|
||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||
require(input.size == n) { "Input size must be $n" }
|
||||
|
||||
// Bit-reversal permutation
|
||||
val real = input.copyOf()
|
||||
val imag = FloatArray(n)
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||
j = j xor bit
|
||||
if (i < j) {
|
||||
val tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// Radix-2 Cooley-Tukey
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val half = len / 2
|
||||
val step = n / len
|
||||
for (i in 0 until n step len) {
|
||||
for (k in 0 until half) {
|
||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||
real[i + k + half] = real[i + k] - tReal
|
||||
imag[i + k + half] = imag[i + k] - tImag
|
||||
real[i + k] += tReal
|
||||
imag[i + k] += tImag
|
||||
}
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first N/2+1 bins)
|
||||
val mag = FloatArray(n / 2 + 1)
|
||||
for (i in 0..n / 2) {
|
||||
mag[i] = kotlin.math.sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||
}
|
||||
return mag
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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/CwFFT.kt
|
||||
git commit -m "feat(cw): add radix-2 FFT for spectrogram processing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Spectrogram engine
|
||||
|
||||
**Objective:** Maintain a sliding-window spectrogram (frequency×time matrix) that updates with each new audio frame.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/.../cw/CwSpectrogram.kt`
|
||||
|
||||
**Key design:**
|
||||
- Window size: 256 samples, hop 64 samples (75% overlap)
|
||||
- Hanning window applied before FFT
|
||||
- Spectrogram history: 40 columns (40 × 8ms = 320ms, enough for longest Morse dash)
|
||||
- Frequency bins: 6-38 (187-1187 Hz), 33 bins total
|
||||
- Energy normalization: per-bin running average
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Sliding-window spectrogram for CW decoding.
|
||||
* Maintains a time-frequency matrix updated with each audio frame.
|
||||
*
|
||||
* Parameters:
|
||||
* fftSize = 256, hopSize = 64, sampleRate = 4000
|
||||
* Frequency bins: 6..38 (187-1187 Hz)
|
||||
* History: 40 columns (320 ms window)
|
||||
*/
|
||||
internal class CwSpectrogram(
|
||||
private val fftSize: Int = 256,
|
||||
private val hopSize: Int = 64,
|
||||
private val sampleRate: Int = 4000,
|
||||
private val minBin: Int = 6, // 187 Hz
|
||||
private val maxBin: Int = 38, // 1187 Hz
|
||||
private val historyCols: Int = 40
|
||||
) {
|
||||
private val fft = CwFFT(fftSize)
|
||||
private val numBins = maxBin - minBin + 1
|
||||
private val hanning = FloatArray(fftSize) {
|
||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||
}
|
||||
|
||||
// Spectrogram data: [timeCol][freqBin]
|
||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||
private var currentCol = 0
|
||||
private var samplesBuffered = 0
|
||||
private val buffer = FloatArray(fftSize)
|
||||
|
||||
// Per-bin running energy for normalization
|
||||
private val binEnergy = FloatArray(numBins) { 1f }
|
||||
private val alpha = 0.95f
|
||||
|
||||
/** Add audio samples, compute FFTs for each complete hop. */
|
||||
fun addSamples(samples: FloatArray) {
|
||||
var offset = 0
|
||||
while (offset < samples.size) {
|
||||
val needed = fftSize - samplesBuffered
|
||||
val copyLen = kotlin.math.min(needed, samples.size - offset)
|
||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||
samplesBuffered += copyLen
|
||||
offset += copyLen
|
||||
|
||||
if (samplesBuffered >= fftSize) {
|
||||
processFrame()
|
||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||
samplesBuffered = fftSize - hopSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processFrame() {
|
||||
// Apply Hanning window
|
||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||
|
||||
// Compute FFT magnitude spectrum
|
||||
val mag = fft.magnitudeSpectrum(windowed)
|
||||
|
||||
// Update spectrogram column
|
||||
val col = spectrogram[currentCol]
|
||||
for (b in 0 until numBins) {
|
||||
val binIdx = minBin + b
|
||||
val rawMag = mag[binIdx]
|
||||
// Running energy normalization
|
||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||
}
|
||||
|
||||
currentCol = (currentCol + 1) % historyCols
|
||||
}
|
||||
|
||||
/** Get the current spectrogram as a 2D array. */
|
||||
fun getSpectrogram(): Array<FloatArray> {
|
||||
// Return in chronological order
|
||||
val result = Array(historyCols) { i ->
|
||||
val srcIdx = (currentCol + i) % historyCols
|
||||
spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the most recent column (current energy across all frequencies). */
|
||||
fun getCurrentColumn(): FloatArray {
|
||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||
return spectrogram[prevCol].copyOf()
|
||||
}
|
||||
|
||||
/** Find the frequency bin with peak energy. */
|
||||
fun findPeakBin(): Int {
|
||||
val col = getCurrentColumn()
|
||||
var maxBin = 0
|
||||
var maxVal = 0f
|
||||
for (i in col.indices) {
|
||||
if (col[i] > maxVal) {
|
||||
maxVal = col[i]
|
||||
maxBin = i
|
||||
}
|
||||
}
|
||||
return if (maxVal > 0.3f) maxBin else -1
|
||||
}
|
||||
|
||||
/** Get energy at a specific bin over the last N columns. */
|
||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||
val result = FloatArray(kotlin.math.min(numCols, historyCols))
|
||||
for (i in result.indices) {
|
||||
val colIdx = (currentCol - 1 - i + historyCols) % historyCols
|
||||
result[result.size - 1 - i] = spectrogram[colIdx][bin]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the bin index for a frequency in Hz. */
|
||||
fun freqToBin(freqHz: Float): Int {
|
||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||
}
|
||||
|
||||
/** Get the center frequency for a bin. */
|
||||
fun binToFreq(bin: Int): Float {
|
||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (col in spectrogram) col.fill(0f)
|
||||
currentCol = 0
|
||||
samplesBuffered = 0
|
||||
buffer.fill(0f)
|
||||
binEnergy.fill(1f)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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/CwSpectrogram.kt
|
||||
git commit -m "feat(cw): add sliding-window spectrogram for multi-channel CW detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Bayesian timing decoder
|
||||
|
||||
**Objective:** Implement a probability-based Morse timing analyzer that replaces hard thresholds with Bayesian probability distributions.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/.../cw/CwBayesianDecoder.kt`
|
||||
|
||||
**Bayesian approach:**
|
||||
- Instead of "is this a dit or a dash?" (hard decision)
|
||||
- Compute: P(dit | duration), P(dash | duration), P(gap | duration)
|
||||
- Use observed timing distributions as prior probabilities
|
||||
- Layer probabilities: signal presence → symbol type → character → word
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Bayesian Morse timing decoder.
|
||||
* Replaces hard thresholds with probability-based decision making.
|
||||
*
|
||||
* Instead of:
|
||||
* if (ratio < 1.5) → dit
|
||||
* else if (ratio < 5.0) → dash
|
||||
*
|
||||
* We compute:
|
||||
* P(dit | duration) = P(duration | dit) * P(dit) / P(duration)
|
||||
* P(dash | duration) = P(duration | dash) * P(dash) / P(duration)
|
||||
*
|
||||
* And pick the most likely interpretation.
|
||||
*/
|
||||
internal class CwBayesianDecoder {
|
||||
|
||||
// Morse timing parameters (will be learned from signal)
|
||||
private var dotDurationMs = 60f // initial 20 WPM
|
||||
private var speedWpm = 20f
|
||||
private var pitchHz = 700f
|
||||
|
||||
// Symbol history for Bayesian inference
|
||||
private val recentDits = mutableListOf<Float>()
|
||||
private val recentDashes = mutableListOf<Float>()
|
||||
private val recentGaps = mutableListOf<Float>()
|
||||
|
||||
// Current symbol being accumulated
|
||||
private var currentSymbol = StringBuilder()
|
||||
private var decodedText = StringBuilder()
|
||||
|
||||
// Output
|
||||
private var _decodedText = ""
|
||||
val decodedText: String get() = _decodedText
|
||||
|
||||
/**
|
||||
* Compute probability that a duration matches a Morse element type.
|
||||
* Uses Gaussian probability density centered on the expected duration.
|
||||
*/
|
||||
private fun probabilityOf(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||
if (varianceMs <= 0f) return 0f
|
||||
val diff = durationMs - expectedMs
|
||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a tone (signal present) duration in milliseconds.
|
||||
* Returns the most likely symbol type and its probability.
|
||||
*/
|
||||
fun processTone(durationMs: Float): SymbolResult {
|
||||
val ditProb = probabilityOf(durationMs, dotDurationMs, dotDurationMs * 0.3f)
|
||||
val dashProb = probabilityOf(durationMs, dotDurationMs * 3, dotDurationMs * 0.5f)
|
||||
|
||||
if (ditProb > dashProb && ditProb > 0.1f) {
|
||||
currentSymbol.append('.')
|
||||
recentDits.add(durationMs)
|
||||
updateSpeedEstimate()
|
||||
return SymbolResult('.', ditProb)
|
||||
} else if (dashProb > 0.1f) {
|
||||
currentSymbol.append('-')
|
||||
recentDashes.add(durationMs)
|
||||
return SymbolResult('-', dashProb)
|
||||
}
|
||||
return SymbolResult(null, 0f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a gap (silence) duration in milliseconds.
|
||||
* Determines if it's intra-char, inter-char, or word gap.
|
||||
* Returns the decoded character if a complete symbol was decoded.
|
||||
*/
|
||||
fun processGap(durationMs: Float): Char? {
|
||||
if (currentSymbol.isEmpty()) {
|
||||
// No symbol in progress — could be a word gap
|
||||
val wordGapProb = probabilityOf(durationMs, dotDurationMs * 7, dotDurationMs * 1.0f)
|
||||
if (wordGapProb > 0.3f) {
|
||||
decodedText.append(' ')
|
||||
_decodedText = decodedText.toString()
|
||||
return ' '
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Inter-char gap vs intra-char gap
|
||||
val interCharProb = probabilityOf(durationMs, dotDurationMs * 3, dotDurationMs * 0.5f)
|
||||
val intraCharProb = probabilityOf(durationMs, dotDurationMs * 1, dotDurationMs * 0.3f)
|
||||
val wordGapProb = probabilityOf(durationMs, dotDurationMs * 7, dotDurationMs * 1.0f)
|
||||
|
||||
return when {
|
||||
wordGapProb > interCharProb && wordGapProb > 0.3f -> {
|
||||
val char = decodeCurrentSymbol()
|
||||
decodedText.append(' ')
|
||||
_decodedText = decodedText.toString()
|
||||
char
|
||||
}
|
||||
interCharProb > intraCharProb && interCharProb > 0.2f -> {
|
||||
val char = decodeCurrentSymbol()
|
||||
_decodedText = decodedText.toString()
|
||||
char
|
||||
}
|
||||
else -> null // intra-char gap, continue building symbol
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeCurrentSymbol(): Char? {
|
||||
if (currentSymbol.isEmpty()) return null
|
||||
val morse = currentSymbol.toString()
|
||||
currentSymbol.clear()
|
||||
val char = morseToChar(morse)
|
||||
if (char != null) {
|
||||
decodedText.append(char)
|
||||
}
|
||||
return char
|
||||
}
|
||||
|
||||
private fun updateSpeedEstimate() {
|
||||
if (recentDits.size < 3) return
|
||||
val sorted = recentDits.sorted()
|
||||
val median = sorted[sorted.size / 2]
|
||||
if (median > 0f) {
|
||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||
speedWpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPitch(pitch: Float) { pitchHz = pitch }
|
||||
|
||||
fun getSpeed(): Float = speedWpm
|
||||
|
||||
fun reset() {
|
||||
dotDurationMs = 60f
|
||||
speedWpm = 20f
|
||||
recentDits.clear()
|
||||
recentDashes.clear()
|
||||
recentGaps.clear()
|
||||
currentSymbol.clear()
|
||||
decodedText.clear()
|
||||
_decodedText = ""
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
data class SymbolResult(val symbol: Char?, val probability: Float)
|
||||
```
|
||||
|
||||
**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/CwBayesianDecoder.kt
|
||||
git commit -m "feat(cw): add Bayesian probability-based timing decoder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Build multi-channel signal tracker
|
||||
|
||||
**Objective:** Create a channel tracker that monitors multiple frequency bins, extracts energy envelopes, and feeds the best one to the Bayesian decoder.
|
||||
|
||||
**Files:**
|
||||
- Create: `core/domain/src/main/java/.../cw/CwChannelTracker.kt`
|
||||
|
||||
**Design:**
|
||||
- Scan spectrogram for active frequency bins (energy > threshold)
|
||||
- For each active bin, extract the energy envelope over time
|
||||
- Track up to 3 channels simultaneously
|
||||
- For each channel, compute signal presence probability
|
||||
- Select the channel with highest confidence for output
|
||||
|
||||
```kotlin
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Multi-channel CW signal tracker.
|
||||
* Monitors the spectrogram for active frequency bins and extracts
|
||||
* energy envelopes for each detected signal.
|
||||
*/
|
||||
internal class CwChannelTracker(
|
||||
private val spectrogram: CwSpectrogram,
|
||||
private val numChannels: Int = 3
|
||||
) {
|
||||
data class Channel(
|
||||
val bin: Int,
|
||||
val frequency: Float,
|
||||
var active: Boolean = false,
|
||||
var energy: Float = 0f,
|
||||
var history: MutableList<Float> = mutableListOf(),
|
||||
var confidence: Float = 0f
|
||||
)
|
||||
|
||||
private val channels = Array(numChannels) { Channel(0, 0f) }
|
||||
private var activeCount = 0
|
||||
|
||||
/** Scan spectrogram and update channel tracking. */
|
||||
fun update(): List<Channel> {
|
||||
val col = spectrogram.getCurrentColumn()
|
||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||
|
||||
// Update existing channels
|
||||
for (ch in channels) {
|
||||
if (ch.active) {
|
||||
// Check if this bin is still active
|
||||
if (peaks.contains(ch.bin)) {
|
||||
ch.energy = col[ch.bin]
|
||||
ch.history.add(ch.energy)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence = computeConfidence(ch.history)
|
||||
} else {
|
||||
// Signal lost — keep for a few frames then deactivate
|
||||
ch.history.add(0f)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence *= 0.9f
|
||||
if (ch.confidence < 0.1f) ch.active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign new peaks to inactive channels
|
||||
var peakIdx = 0
|
||||
for (ch in channels) {
|
||||
if (!ch.active && peakIdx < peaks.size) {
|
||||
val bin = peaks[peakIdx]
|
||||
ch.bin = bin
|
||||
ch.frequency = spectrogram.binToFreq(bin)
|
||||
ch.active = true
|
||||
ch.energy = col[bin]
|
||||
ch.history.clear()
|
||||
ch.confidence = 0.5f
|
||||
peakIdx++
|
||||
}
|
||||
}
|
||||
|
||||
activeCount = channels.count { it.active }
|
||||
return channels.filter { it.active }
|
||||
}
|
||||
|
||||
/** Find peak bins in the current spectrum. */
|
||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||
val peaks = mutableListOf<Int>()
|
||||
for (i in 1 until spectrum.size - 1) {
|
||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||
// Check minimum distance from existing peaks
|
||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||
peaks.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
peaks.sortByDescending { spectrum[it] }
|
||||
return peaks
|
||||
}
|
||||
|
||||
/** Compute confidence score from energy history. */
|
||||
private fun computeConfidence(history: List<Float>): Float {
|
||||
if (history.size < 10) return 0.3f
|
||||
val recent = history.takeLast(10)
|
||||
val mean = recent.average().toFloat()
|
||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||
// Lower variance = more stable signal = higher confidence
|
||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/** Get the best channel (highest confidence). */
|
||||
fun getBestChannel(): Channel? {
|
||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (ch in channels) {
|
||||
ch.bin = 0; ch.frequency = 0f; ch.active = false
|
||||
ch.energy = 0f; ch.history.clear(); ch.confidence = 0f
|
||||
}
|
||||
activeCount = 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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/CwChannelTracker.kt
|
||||
git commit -m "feat(cw): add multi-channel signal tracker for spectrogram peak detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integrate into new CwDecoder
|
||||
|
||||
**Objective:** Replace the existing CwDecoder with the new spectrogram-based multi-channel decoder.
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/domain/src/main/java/.../cw/CwDecoder.kt` (complete rewrite)
|
||||
- Delete (optional): `CwResampler.kt`, `CwFilter.kt`, `CwGoertzel.kt`, `CwSTFFT.kt` (no longer needed)
|
||||
|
||||
**New CwDecoder flow:**
|
||||
```
|
||||
processBuffer(buffer):
|
||||
1. Feed samples to spectrogram
|
||||
2. Update channel tracker
|
||||
3. For each active channel:
|
||||
a. Extract energy envelope
|
||||
b. Detect signal presence (with Bayesian probability)
|
||||
c. Measure tone/gap durations
|
||||
d. Feed to Bayesian decoder
|
||||
4. Select best channel's output
|
||||
```
|
||||
|
||||
```kotlin
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000
|
||||
) {
|
||||
private val spectrogram = CwSpectrogram(
|
||||
fftSize = 256, hopSize = 64,
|
||||
sampleRate = sampleRate, minBin = 6, maxBin = 38
|
||||
)
|
||||
private val channelTracker = CwChannelTracker(spectrogram)
|
||||
private val bayesianDecoder = CwBayesianDecoder()
|
||||
|
||||
// Per-channel state tracking
|
||||
private data class ChannelState(
|
||||
var isSignal: Boolean = false,
|
||||
var toneSamples: Int = 0,
|
||||
var gapSamples: Int = 0,
|
||||
var lastThreshold: Float = 0f
|
||||
)
|
||||
private val channelStates = Array(3) { ChannelState() }
|
||||
|
||||
// Output flows
|
||||
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
|
||||
|
||||
// Timing: 1 sample at 4 kHz = 0.25 ms
|
||||
private val samplePeriodMs = 1000f / 4000 // 0.25 ms
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Update spectrogram
|
||||
spectrogram.addSamples(buffer)
|
||||
|
||||
// 2. Update channel tracker
|
||||
val activeChannels = channelTracker.update()
|
||||
|
||||
// 3. Process each active channel
|
||||
for ((idx, channel) in activeChannels.withIndex()) {
|
||||
if (idx >= channelStates.size) break
|
||||
val state = channelStates[idx]
|
||||
val col = spectrogram.getCurrentColumn()
|
||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||
|
||||
// Adaptive threshold for this channel
|
||||
val noiseFloor = 0.3f
|
||||
val threshold = noiseFloor + (energy - noiseFloor) * 0.3f
|
||||
state.lastThreshold = threshold
|
||||
|
||||
// Signal present?
|
||||
if (energy > threshold) {
|
||||
if (!state.isSignal) {
|
||||
// Rising edge — process gap
|
||||
if (state.gapSamples > 0) {
|
||||
val gapMs = state.gapSamples * samplePeriodMs
|
||||
bayesianDecoder.processGap(gapMs)
|
||||
}
|
||||
state.gapSamples = 0
|
||||
state.isSignal = true
|
||||
}
|
||||
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 output from best channel
|
||||
val bestChannel = channelTracker.getBestChannel()
|
||||
if (bestChannel != null) {
|
||||
_estimatedPitch.value = bestChannel.frequency
|
||||
_signalStrength.value = bestChannel.confidence
|
||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||
}
|
||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
spectrogram.reset()
|
||||
channelTracker.reset()
|
||||
bayesianDecoder.reset()
|
||||
for (state in channelStates) {
|
||||
state.isSignal = false; state.toneSamples = 0
|
||||
state.gapSamples = 0; state.lastThreshold = 0f
|
||||
}
|
||||
_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): replace with spectrogram-based multi-channel Bayesian decoder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rewrite unit tests
|
||||
|
||||
**Objective:** Update tests to cover the new architecture — FFT, spectrogram, Bayesian probability, multi-channel tracking.
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/domain/src/test/java/.../cw/CwDecoderTest.kt`
|
||||
|
||||
**Key test additions:**
|
||||
- `fft_magnitudeSpectrum_detectsTone()` — generate 700 Hz tone, assert peak at correct bin
|
||||
- `fft_magnitudeSpectrum_silence_isFlat()` — all-zero input, flat spectrum
|
||||
- `spectrogram_addSamples_updatesEnergy()` — single tone increases energy at its bin
|
||||
- `spectrogram_findPeakBin_returnsCorrectBin()` — strongest tone found
|
||||
- `spectrogram_binToFreq_roundtrip()` — freq→bin→freq is consistent
|
||||
- `bayesian_processTone_dit()` — short tone produces dit
|
||||
- `bayesian_processTone_dash()` — 3x tone produces dash
|
||||
- `bayesian_processGap_interChar()` — gap produces character
|
||||
- `bayesian_processGap_wordGap()` — long gap adds space
|
||||
- `channelTracker_update_createsChannels()` — single tone creates one channel
|
||||
- `channelTracker_findPeaks_multipleTones()` — multiple tones create multiple channels
|
||||
- `decoder_fullPipeline()` — end-to-end silence→no crash
|
||||
|
||||
**Step 1: Run tests**
|
||||
|
||||
Run: `./gradlew :core:domain:test --no-daemon`
|
||||
Expected: BUILD SUCCESSFUL, 15+ tests pass
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add core/domain/src/test/java/.../cw/CwDecoderTest.kt
|
||||
git commit -m "test(cw): add tests for spectrogram, FFT, Bayesian decoder, channel tracker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 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): v3 spectrogram-based multi-channel Bayesian decoder"
|
||||
git push fork main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `core/domain/src/main/java/.../cw/CwFFT.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwSpectrogram.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwBayesianDecoder.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwChannelTracker.kt` | Create |
|
||||
| `core/domain/src/main/java/.../cw/CwDecoder.kt` | Rewrite |
|
||||
| `core/domain/src/main/java/.../cw/CwResampler.kt` | Keep (unused, can delete) |
|
||||
| `core/domain/src/main/java/.../cw/CwFilter.kt` | Keep (unused, can delete) |
|
||||
| `core/domain/src/main/java/.../cw/CwGoertzel.kt` | Keep (unused, can delete) |
|
||||
| `core/domain/src/main/java/.../cw/CwSTFFT.kt` | Keep (unused, can delete) |
|
||||
| `core/domain/src/test/java/.../cw/CwDecoderTest.kt` | Rewrite |
|
||||
|
||||
## Risks / tradeoffs
|
||||
|
||||
- **CPU cost:** FFT every 64 samples at 4 kHz = 62.5 FFTs/second. 256-point radix-2 FFT is ~2,500 float ops. On a modern phone this is negligible (< 0.1% CPU).
|
||||
- **Memory:** Spectrogram = 40 cols × 33 bins × 4 bytes = ~5 KB. Trivial.
|
||||
- **Frequency resolution:** 31.25 Hz per bin. Fine enough for CW (typical tone stability is ±10 Hz, but ±50 Hz is still fine).
|
||||
- **Time resolution:** 8 ms per column. At 20 WPM, a dit is 60 ms = 7.5 columns. Enough for accurate timing.
|
||||
- **Multi-channel complexity:** Current implementation tracks up to 3 channels. In practice, the strongest signal is usually the desired one. The channel tracker handles this by selecting highest confidence.
|
||||
- **Bayesian advantage:** The probability-based approach naturally handles ambiguous timing. A tone that's between dit and dash length won't be forced into a wrong category — it'll have low probability for both, and the decoder can wait for more context.
|
||||
- **Backward compatibility:** `CwDecoder` class name and public interface unchanged. UI/ViewModel work without modification.
|
||||
- **Noise performance:** The normalized spectrogram (per-bin energy tracking) naturally handles varying noise floors. A signal that's 2x above the noise floor at its bin will be detected regardless of absolute level.
|
||||
@@ -1,169 +0,0 @@
|
||||
# Look4Sat Chinese Translation Completion
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Complete the Chinese (zh) translation of Look4Sat by adding ~60 missing strings that were introduced in versions after 4.0, while preserving the existing 154-line translation.
|
||||
|
||||
**Architecture:** Single file change — update `values-zh/strings.xml` to match the English `values/strings.xml` with all missing strings translated.
|
||||
|
||||
**Tech Stack:** Android XML resource files.
|
||||
|
||||
---
|
||||
|
||||
## Current Context
|
||||
|
||||
- **Existing Chinese translation:** `core/presentation/src/main/res/values-zh/strings.xml` — 154 lines, covers basic UI but missing newer features
|
||||
- **English reference:** `core/presentation/src/main/res/values/strings.xml` — 216 lines
|
||||
- **Missing strings:** Doppler calculator, CW decoder, CAT radio control, data import errors, and several settings strings
|
||||
- **Existing translations preserved:** All 154 lines of existing Chinese are good and kept as-is
|
||||
|
||||
### Strings already in Chinese (kept unchanged)
|
||||
|
||||
All existing strings from `values-zh/strings.xml` — navigation, satellite screen, passes screen, radar screen (basic), map screen, settings (station, data, network, bluetooth, other), highlight, outro.
|
||||
|
||||
### Strings missing from Chinese (need translation)
|
||||
|
||||
| Name | English | Chinese Translation |
|
||||
|------|---------|-------------------|
|
||||
| `radar_doppler_calc` | Doppler Frequency Calculator | 多普勒频率计算器 |
|
||||
| `radar_doppler_tx_hint` | Enter TX freq (MHz) | 输入上行频率 (MHz) |
|
||||
| `radar_doppler_rx_hint` | Enter RX freq (MHz) | 输入下行频率 (MHz) |
|
||||
| `radar_doppler_offset_hint` | Offset (kHz) | 偏移 (kHz) |
|
||||
| `radar_doppler_info` | For linear transponders, type one frequency to see the other | 线性转发器:输入一个频率即可算出另一个 |
|
||||
| `radar_cw_decoder` | CW Decoder | CW 解码器 |
|
||||
| `radar_cw_start` | Start | 开始 |
|
||||
| `radar_cw_stop` | Stop | 停止 |
|
||||
| `radar_cw_reset` | Clear | 清空 |
|
||||
| `prefs_donate_title` | Donate | 支持(已有) |
|
||||
| `prefs_privacy_title` | Privacy Policy | 隐私政策(已有) |
|
||||
| `prefs_data_import_satellites_error` | No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file. | 未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。 |
|
||||
| `prefs_data_import_transceivers_error` | No transceivers imported. Select a valid SatNOGS (.json) file. | 未导入收发器。请选择有效的 SatNOGS (.json) 文件。 |
|
||||
| `nav_radiocontrol` | Radio Control | 电台控制 |
|
||||
| `rc_settings_title` | CAT Radio Control | CAT 电台控制 |
|
||||
| `rc_radio_model` | Radio Model | 电台型号 |
|
||||
| `rc_tx_device_hint` | TX Radio BT Address | 发射电台蓝牙地址 |
|
||||
| `rc_rx_device_hint` | RX Radio BT Address | 接收电台蓝牙地址 |
|
||||
| `rc_tx_name_hint` | TX Radio Name | 发射电台名称 |
|
||||
| `rc_rx_name_hint` | RX Radio Name | 接收电台名称 |
|
||||
| `rc_enable_switch` | Enable CAT Control | 启用 CAT 控制 |
|
||||
| `prefs_cat_output` | CAT | CAT |
|
||||
| `prefs_outro_thanks` | (updated version with xdsopl and Robot36) | 在现有翻译末尾加上 `\\n* xdsopl 和 Robot36 贡献者!` |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: Update values-zh/strings.xml with all missing strings
|
||||
|
||||
**Objective:** Add all missing Chinese translations to the existing file, preserving existing translations.
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/presentation/src/main/res/values-zh/strings.xml`
|
||||
|
||||
**Changes needed:**
|
||||
|
||||
1. **Add Doppler calculator strings** (after `radar_visible`):
|
||||
```xml
|
||||
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||
```
|
||||
|
||||
2. **Add CW decoder strings** (after Doppler):
|
||||
```xml
|
||||
<string name="radar_cw_decoder">CW 解码器</string>
|
||||
<string name="radar_cw_start">开始</string>
|
||||
<string name="radar_cw_stop">停止</string>
|
||||
<string name="radar_cw_reset">清空</string>
|
||||
```
|
||||
|
||||
3. **Add data import error strings** (after `prefs_data_update_success`):
|
||||
```xml
|
||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
```
|
||||
|
||||
4. **Add CAT radio control section** (after `prefs_bt_output`):
|
||||
```xml
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
```
|
||||
|
||||
5. **Add radio control section** (after `prefs_cat_output`):
|
||||
```xml
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">电台控制</string>
|
||||
<string name="rc_settings_title">CAT 电台控制</string>
|
||||
<string name="rc_radio_model">电台型号</string>
|
||||
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
```
|
||||
|
||||
6. **Update `prefs_outro_thanks`** to include the new contributors:
|
||||
```xml
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat 所有用户及贡献者!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
</string>
|
||||
```
|
||||
|
||||
**Step 1: Verify the file compiles**
|
||||
|
||||
Since we're modifying an Android resource XML, verify it's valid by building:
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug --no-daemon
|
||||
```
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 2: Verify Chinese is loaded**
|
||||
|
||||
The app should now show Chinese text when the system language is set to Chinese. No code changes are needed since Android's resource system automatically picks the `values-zh` folder.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add core/presentation/src/main/res/values-zh/strings.xml
|
||||
git commit -m "i18n(zh): complete Chinese translation with new features
|
||||
|
||||
- Add Doppler frequency calculator strings
|
||||
- Add CW decoder strings
|
||||
- Add CAT radio control strings
|
||||
- Add data import error messages
|
||||
- Update outro thanks with new contributors
|
||||
- Preserve all existing 154 lines of translation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `core/presentation/src/main/res/values-zh/strings.xml` | Modify (add ~60 lines) |
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build: `./gradlew :app:assembleDebug --no-daemon` → BUILD SUCCESSFUL
|
||||
2. Install APK on device with system language set to Chinese
|
||||
3. Check all screens show Chinese text:
|
||||
- Satellite screen: 搜索、类型、全选等
|
||||
- Passes screen: 过境、仰角、AOS 等
|
||||
- Radar screen: 多普勒频率计算器、CW 解码器
|
||||
- Settings: CAT 电台控制、数据导入错误信息
|
||||
- Map: 方位角、仰角、高度等
|
||||
|
||||
## Risks / tradeoffs
|
||||
|
||||
- **None.** This is a pure translation update with no code changes. Existing translations are preserved.
|
||||
- The `prefs_outro_thanks` string is marked `translatable="false"` in the English version (because it contains proper names), but in the Chinese version it was already translated, so we keep it translated.
|
||||
- The `app_name` is `translatable="false"` in English, so it's not included in the Chinese file.
|
||||
@@ -1,113 +0,0 @@
|
||||
# Mutual Pass & Elevation Curve Feature
|
||||
|
||||
> **For Hermes:** Implementation plan for dual-station mutual pass query and draggable elevation curve.
|
||||
|
||||
**Goal:** Port satlover.de's "对台过境查询" (dual-station mutual pass query) and draggable dual elevation curve into Look4Sat.
|
||||
|
||||
**Architecture:**
|
||||
- New feature module `feature/mutual/` following existing feature plugin pattern
|
||||
- Mutual pass logic: compute passes for two stations, find overlapping time windows
|
||||
- Elevation curve: Canvas-based composable with drag gesture
|
||||
|
||||
**Tech Stack:** Kotlin, Jetpack Compose, Canvas, predict4java (existing)
|
||||
|
||||
---
|
||||
|
||||
## Module Structure
|
||||
|
||||
### New files to create:
|
||||
|
||||
```
|
||||
feature/mutual/
|
||||
├── build.gradle.kts
|
||||
└── src/main/java/com/rtbishop/look4sat/feature/mutual/
|
||||
├── MutualPass.kt # data class for a mutual pass
|
||||
├── MutualViewModel.kt # ViewModel for dual-station logic
|
||||
├── MutualScreen.kt # Main screen composable
|
||||
├── MutualInputSection.kt # Location input section
|
||||
├── MutualResultList.kt # Mutual pass results list
|
||||
└── ElevationCurveChart.kt # Draggable dual elevation curve
|
||||
```
|
||||
|
||||
### Existing files to modify:
|
||||
|
||||
- `settings.gradle.kts` — add `:feature:mutual` include
|
||||
- `app/build.gradle.kts` — add `implementation(project(":feature:mutual"))`
|
||||
- `app/.../MainActivity.kt` or navigation — add mutual pass route
|
||||
- `core/presentation/.../strings.xml` — add string resources (Chinese + English)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create feature/mutual module
|
||||
|
||||
**Files:**
|
||||
- Create: `feature/mutual/build.gradle.kts`
|
||||
- Modify: `settings.gradle.kts` — add `include(":feature:mutual")`
|
||||
- Modify: `app/build.gradle.kts` — add `implementation(project(":feature:mutual"))`
|
||||
|
||||
## Task 2: Add MutualPass data model
|
||||
|
||||
**Files:**
|
||||
- Create: `feature/mutual/src/main/java/.../MutualPass.kt`
|
||||
|
||||
**Data class:**
|
||||
```kotlin
|
||||
data class MutualPass(
|
||||
val catNum: Int,
|
||||
val name: String,
|
||||
val startTime: Long, // common AOS time
|
||||
val endTime: Long, // common LOS time
|
||||
val maxElevationA: Double,
|
||||
val maxElevationB: Double,
|
||||
val elevationSamples: List<Pair<Long, Pair<Double, Double>>> // time -> (elevA, elevB)
|
||||
)
|
||||
```
|
||||
|
||||
## Task 3: MutualViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `feature/mutual/src/main/java/.../MutualViewModel.kt`
|
||||
|
||||
**Logic:**
|
||||
- Input: station A position, station B position, time range, selected satellites
|
||||
- For each satellite: compute passes for A and B, find overlapping windows
|
||||
- For each overlap: sample elevation every N seconds, store in MutualPass
|
||||
- Return sorted list of mutual passes
|
||||
|
||||
## Task 4: ElevationCurveChart composable
|
||||
|
||||
**Files:**
|
||||
- Create: `feature/mutual/src/main/java/.../ElevationCurveChart.kt`
|
||||
|
||||
**Features:**
|
||||
- Canvas draw: background grid, elevation labels, two curves (station A color, station B color)
|
||||
- Drag gesture: draggable vertical line + time label
|
||||
- Time axis: show time labels at regular intervals
|
||||
|
||||
## Task 5: MutualScreen UI
|
||||
|
||||
**Files:**
|
||||
- Create: `feature/mutual/src/main/java/.../MutualInputSection.kt`
|
||||
- Create: `feature/mutual/src/main/java/.../MutualResultList.kt`
|
||||
- Create: `feature/mutual/src/main/java/.../MutualScreen.kt`
|
||||
|
||||
**Layout:**
|
||||
- Top: two station location inputs (lat/lon + min elevation)
|
||||
- Middle: time range selector + satellite selector
|
||||
- Bottom: results list with elevation curve for each mutual pass
|
||||
|
||||
## Task 6: Navigation + Strings
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/.../MainActivity.kt` or navigation setup
|
||||
- Modify: `core/presentation/.../values/strings.xml` + `values-zh/strings.xml`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build: `./gradlew :app:assembleDebug` → BUILD SUCCESSFUL
|
||||
2. Navigate to mutual pass screen from main navigation
|
||||
3. Input two locations, select satellites, query → see mutual passes
|
||||
4. Tap a mutual pass → see elevation curve with drag
|
||||
5. Test with different time zones and UTC
|
||||
Reference in new issue
Block a user