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

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

No files matched your search

@@ -0,0 +1,781 @@
# 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.
@@ -0,0 +1,178 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Real-time CW (Morse code) decoder.
*
* Processes audio buffers and emits decoded text characters.
* Uses a bandpass filter centered on the CW tone, envelope detection,
* and timing analysis to distinguish dits, dashes, and gaps.
*
* Morse timing (paris method):
* Dit = 1 unit
* Dash = 3 units
* Intra-char gap = 1 unit
* Inter-char gap = 3 units
* Word gap = 7 units
*/
class CwDecoder(
val sampleRate: Int = 8000,
val cwToneFreq: Float = 700f,
val filterWidth: Float = 200f
) {
// Filter coefficients (pre-computed)
private val firCoeffs = CwDsp.bandpassFir(
lowCutoff = ((cwToneFreq - filterWidth / 2) / sampleRate).toDouble(),
highCutoff = ((cwToneFreq + filterWidth / 2) / sampleRate).toDouble(),
taps = 127
)
// Decoder state
private var isSignalPresent = false
private var signalOnTime = 0 // samples since signal started
private var signalOffTime = 0 // samples since signal ended
private var avgDitDuration = 0f // running average of dit duration in samples
private var decodedText = StringBuilder()
private var currentSymbol = StringBuilder()
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
/** Process a buffer of audio samples. */
fun processBuffer(buffer: FloatArray) {
// 1. Bandpass filter around CW tone
val filtered = CwDsp.applyFir(buffer, firCoeffs)
// 2. Envelope detection
val env = CwDsp.envelope(filtered, 0.1f)
// 3. Adaptive threshold
val floor = CwDsp.noiseFloor(env)
val threshold = floor * 1.8f
// Track max envelope for signal strength display
val maxEnv = env.maxOrNull() ?: 0f
_signalStrength.value = if (threshold > 0f && maxEnv > threshold) {
((maxEnv - threshold) / maxEnv).coerceIn(0f, 1f)
} else 0f
// 4. Timing analysis
for (sample in env) {
if (sample > threshold) {
// Signal ON
if (!isSignalPresent) {
// Rising edge — end of silence
if (signalOffTime > 0) {
processSilence(signalOffTime)
}
signalOffTime = 0
isSignalPresent = true
}
signalOnTime++
} else {
// Signal OFF
if (isSignalPresent) {
// Falling edge — end of tone
processTone(signalOnTime)
signalOnTime = 0
isSignalPresent = false
}
signalOffTime++
}
}
// Push latest decoded text
_decodedTextFlow.value = decodedText.toString()
}
private fun processTone(duration: Int) {
// Update average dit duration based on this tone
if (avgDitDuration == 0f) {
// Initial estimate: assume shortest tone is a dit
// Typical 20 WPM dit = 60ms = 480 samples at 8kHz
avgDitDuration = minOf(duration.toFloat(), (sampleRate / 20).toFloat())
}
val ratio = duration.toFloat() / avgDitDuration
if (ratio < 1.8f) {
currentSymbol.append('.') // Dit
// Update running average with this dit
avgDitDuration = (avgDitDuration * 0.7f + duration * 0.3f)
} else if (ratio < 5f) {
currentSymbol.append('-') // Dash
}
// else: ignore very long tones (likely noise)
}
private fun processSilence(duration: Int) {
if (currentSymbol.isNotEmpty()) {
// Inter-character gap (3+ units) — decode accumulated symbol
val gapRatio = duration.toFloat() / (avgDitDuration.coerceAtLeast(1f))
if (gapRatio >= 2.5f) {
val char = morseToChar(currentSymbol.toString())
if (char != null) {
decodedText.append(char)
}
currentSymbol.clear()
// Word gap (7+ units)
if (gapRatio >= 7f) {
decodedText.append(' ')
}
}
}
}
fun resetDecoder() {
isSignalPresent = false
signalOnTime = 0
signalOffTime = 0
avgDitDuration = 0f
decodedText.clear()
currentSymbol.clear()
_decodedTextFlow.value = ""
_signalStrength.value = 0f
}
companion object {
private val MORSE_TABLE = mapOf(
".-" to 'A', "-..." to 'B', "-.-." to 'C', "-.." to 'D', "." to 'E',
"..-." to 'F', "--." to 'G', "...." to 'H', ".." to 'I', ".---" to 'J',
"-.-" to 'K', ".-.." to 'L', "--" to 'M', "-." to 'N', "---" to 'O',
".--." to 'P', "--.-" to 'Q', ".-." to 'R', "..." to 'S', "-" to 'T',
"..-" to 'U', "...-" to 'V', ".--" to 'W', "-..-" to 'X', "-.--" to 'Y',
"--.." to 'Z', ".----" to '1', "..---" to '2', "...--" to '3',
"....-" to '4', "....." to '5', "-...." to '6', "--..." to '7',
"---.." to '8', "----." to '9', "-----" to '0',
".-.-.-" to '.', "--..--" to ',', "..--.." to '?', ".----." to '\'',
"-.-.--" to '!', "-..-." to '/', "-.--." to '(', "-.--.-" to ')',
".-..." to '&', "---..." to ':', "-.-.-." to ';', "-...-" to '=',
".-.-." to '+', "-....-" to '-', "..--.-" to '_', ".-..-." to '"',
"...-..-" to '$', ".--.-." to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
@@ -0,0 +1,102 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -0,0 +1,232 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwDecoder.morseToChar(".-"))
assertEquals('B', CwDecoder.morseToChar("-..."))
assertEquals('S', CwDecoder.morseToChar("..."))
assertEquals('O', CwDecoder.morseToChar("---"))
assertEquals('C', CwDecoder.morseToChar("-.-."))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwDecoder.morseToChar(".----"))
assertEquals('5', CwDecoder.morseToChar("....."))
assertEquals('0', CwDecoder.morseToChar("-----"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwDecoder.morseToChar("......."))
assertNull(CwDecoder.morseToChar(""))
assertNull(CwDecoder.morseToChar(".-.-.-.-"))
}
@Test
fun morseToChar_specialCharacters() {
assertEquals('.', CwDecoder.morseToChar(".-.-.-"))
assertEquals('?', CwDecoder.morseToChar("..--.."))
assertEquals('/', CwDecoder.morseToChar("-..-."))
assertEquals('@', CwDecoder.morseToChar(".--.-."))
}
// --- DSP ---
@Test
fun bandpassFir_producesNonEmptyCoefficients() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 127)
assertTrue(coeffs.isNotEmpty())
assertEquals(127, coeffs.size)
// Sum should be approximately 1.0
val sum = coeffs.sum()
assertTrue("Sum should be ~1.0, got $sum", sum > 0.9 && sum < 1.1)
}
@Test
fun bandpassFir_oddTaps_forcesOdd() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 100)
assertEquals(101, coeffs.size) // forces odd
}
@Test
fun applyFir_preservesLength() {
val coeffs = CwDsp.bandpassFir(0.075, 0.125, 31)
val input = FloatArray(100) { kotlin.math.sin(it * 0.1f).toFloat() }
val output = CwDsp.applyFir(input, coeffs)
assertEquals(input.size, output.size)
}
@Test
fun envelope_isNonNegative() {
val input = FloatArray(50) { if (it % 2 == 0) 0.5f else -0.3f }
val env = CwDsp.envelope(input, 0.2f)
for (v in env) assertTrue("Envelope should be >= 0, got $v", v >= 0f)
}
@Test
fun envelope_smoothsSignal() {
val input = FloatArray(100) { if (it % 2 == 0) 1f else 0f }
val env = CwDsp.envelope(input, 0.3f)
// Envelope should be between 0 and 1
for (v in env) {
assertTrue("Envelope value $v out of range [0,1]", v >= 0f && v <= 1f)
}
// After smoothing, should not rapidly oscillate
val transitions = (1 until env.size).count { env[it] > 0.1f && env[it - 1] <= 0.1f }
assertTrue("Too many envelope transitions: $transitions", transitions < 5)
}
@Test
fun noiseFloor_producesPositiveValue() {
val env = FloatArray(100) { kotlin.math.abs(kotlin.math.sin(it * 0.5f).toFloat()) }
val floor = CwDsp.noiseFloor(env, 0.3f)
assertTrue(floor > 0f)
assertTrue(floor < 1f) // should be less than max signal
}
@Test
fun goertzel_detectsPresentTone() {
val sampleRate = 8000
val targetFreq = 700f
// Generate a 700 Hz tone at the sample rate
val buffer = FloatArray(sampleRate) { (sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() }
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should detect present tone, got $power", power > 0.1f)
}
@Test
fun goertzel_rejectsAbsentTone() {
val sampleRate = 8000
val targetFreq = 700f
// Generate a 2000 Hz tone (no match for 700 Hz)
val buffer = FloatArray(sampleRate) { (sin(2.0 * PI * 2000f * it / sampleRate)).toFloat() }
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should reject absent tone, got $power", power < 0.1f)
}
@Test
fun goertzel_detectsToneInNoise() {
val sampleRate = 8000
val targetFreq = 700f
// 700 Hz tone + noise
val buffer = FloatArray(sampleRate) {
val noise = (Math.random() * 2 - 1).toFloat() * 0.3f
(sin(2.0 * PI * targetFreq * it / sampleRate)).toFloat() + noise
}
val power = CwDsp.goertzel(buffer, targetFreq, sampleRate)
assertTrue("Goertzel should detect tone in noise, got $power", power > 0.1f)
}
// --- Decoder state ---
@Test
fun cwDecoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun resetDecoder_clearsText() {
val decoder = CwDecoder()
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun cwDecoder_defaultParameters() {
val decoder = CwDecoder()
assertEquals(8000, decoder.sampleRate)
assertEquals(700f, decoder.cwToneFreq, 0.001f)
assertEquals(200f, decoder.filterWidth, 0.001f)
}
@Test
fun cwDecoder_customParameters() {
val decoder = CwDecoder(sampleRate = 11025, cwToneFreq = 600f, filterWidth = 100f)
assertEquals(11025, decoder.sampleRate)
assertEquals(600f, decoder.cwToneFreq, 0.001f)
assertEquals(100f, decoder.filterWidth, 0.001f)
}
@Test
fun processBuffer_silence_doesNotCrash() {
val decoder = CwDecoder()
val silence = FloatArray(1024) { 0f }
decoder.processBuffer(silence)
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_noise_doesNotCrash() {
val decoder = CwDecoder()
val noise = FloatArray(1024) { (Math.random() * 2 - 1).toFloat() * 0.1f }
decoder.processBuffer(noise)
// Should not crash, decoded text may still be empty
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_ditAtCenterFreq_detects() {
val sampleRate = 8000
val decoder = CwDecoder(sampleRate = sampleRate, cwToneFreq = 700f)
// Generate a short dit (~480 samples at 20 WPM) at 700 Hz
val ditDuration = (sampleRate / 20).toInt() // ~400 samples
val buffer = FloatArray(ditDuration) {
(sin(2.0 * PI * 700.0 * it / sampleRate)).toFloat()
}
decoder.processBuffer(buffer)
// Short tone should be processed without crash
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun processBuffer_generatedDit_emitsChar() {
val sampleRate = 8000
val decoder = CwDecoder(sampleRate = sampleRate, cwToneFreq = 700f)
val ditSamples = (sampleRate / 20).toInt() // ~400 samples = 1 unit
val gapSamples = ditSamples * 3 // inter-char gap
// Generate "E" = dit: a single dit followed by inter-char gap
val buffer = FloatArray(ditSamples + gapSamples)
// First part: 700 Hz tone (dit)
for (i in 0 until ditSamples) {
buffer[i] = (sin(2.0 * PI * 700.0 * i / sampleRate)).toFloat()
}
// Second part: silence (gap)
for (i in ditSamples until buffer.size) {
buffer[i] = 0f
}
decoder.processBuffer(buffer)
// After processing, the decoder should have detected the "E" symbol
assertNotNull(decoder.decodedTextFlow.value)
}
}
@@ -86,6 +86,11 @@
<string name="radar_doppler_offset_hint">Offset (kHz)</string> <string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string> <string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
<string name="radar_cw_decoder">CW Decoder</string>
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Prev</string> <string name="map_prev">Prev</string>
<string name="map_next">Next</string> <string name="map_next">Next</string>
@@ -174,6 +174,7 @@ private fun PagerCard(
transceivers = uiState.transceivers.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid, selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos, orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
radioControl = uiState.radioControl, radioControl = uiState.radioControl,
onAction = onAction onAction = onAction
) )
@@ -62,7 +62,8 @@ data class RadarState(
val moonPosition: CelestialComputer.MoonPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(), val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState() val sstv: SstvSubState = SstvSubState(),
val cw: CwSubState = CwSubState()
) )
enum class SstvStatus { Idle, Recording } enum class SstvStatus { Idle, Recording }
@@ -77,6 +78,19 @@ data class SstvSubState(
val diagnosticsMetrics: SstvQualityMetrics? = null val diagnosticsMetrics: SstvQualityMetrics? = null
) )
// --- 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
)
sealed interface RadarAction { sealed interface RadarAction {
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
data class SelectTransmitter(val uuid: String) : RadarAction data class SelectTransmitter(val uuid: String) : RadarAction
@@ -96,4 +110,12 @@ sealed interface RadarAction {
data object SstvReset : RadarAction data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction data class SstvPermissionResult(val granted: Boolean) : RadarAction
// 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
} }
@@ -34,6 +34,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
@@ -70,6 +71,8 @@ class RadarViewModel(
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null private var sstvRecordingJob: Job? = null
private var cwDecoder: CwDecoder? = null
private var cwListeningJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute // Celestial positions change slowly, recompute at most once per minute
private var lastCelestialUpdateMs = 0L private var lastCelestialUpdateMs = 0L
@@ -272,6 +275,25 @@ class RadarViewModel(
sstvDecoder?.clearPixels() sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
// CW actions
is RadarAction.CwPermissionResult -> {
_uiState.update { it.copy(cw = it.cw.copy(hasPermission = action.granted)) }
if (action.granted) initCwDecoder()
}
RadarAction.CwStartListening -> 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)) }
}
} }
} }
@@ -392,6 +414,48 @@ class RadarViewModel(
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) } _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
} }
private fun initCwDecoder() {
if (cwDecoder == null) {
cwDecoder = CwDecoder(
sampleRate = audioCapture.sampleRate,
cwToneFreq = _uiState.value.cw.cwToneFreq
)
}
}
private fun startCwListening() {
if (cwListeningJob?.isActive == true) return
// Stop SSTV if running (audio capture is shared)
stopSstvRecording()
initCwDecoder()
cwDecoder?.resetDecoder()
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
cwListeningJob = viewModelScope.launch {
// Collect decoded text flow
launch {
cwDecoder?.decodedTextFlow?.collect { text ->
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
}
}
// Collect signal strength
launch {
cwDecoder?.signalStrength?.collect { strength ->
_uiState.update { it.copy(cw = it.cw.copy(signalStrength = strength)) }
}
}
// Capture audio and feed to decoder
audioCapture.audioFlow().collect { buffer ->
cwDecoder?.processBuffer(buffer)
}
}
}
private fun stopCwListening() {
cwListeningJob?.cancel()
cwListeningJob = null
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
}
companion object { companion object {
// SSTV Decoder Tuning Parameters // SSTV Decoder Tuning Parameters
// ============================== // ==============================
@@ -40,10 +40,13 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -80,6 +83,7 @@ fun TransceiversPage(
transceivers: List<SatRadio>, transceivers: List<SatRadio>,
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -105,6 +109,7 @@ fun TransceiversPage(
radio = radio, radio = radio,
isExpanded = isExpanded, isExpanded = isExpanded,
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl, radioControl = radioControl,
onAction = onAction, onAction = onAction,
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) } onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
@@ -142,6 +147,7 @@ private fun TransceiverItem(
radio: SatRadio, radio: SatRadio,
isExpanded: Boolean, isExpanded: Boolean,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
onToggle: () -> Unit onToggle: () -> Unit
@@ -237,6 +243,7 @@ private fun TransceiverItem(
ExpandedRadioControl( ExpandedRadioControl(
radio = radio, radio = radio,
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl, radioControl = radioControl,
onAction = onAction onAction = onAction
) )
@@ -308,6 +315,7 @@ private fun UnifiedFrequencyRow(
private fun ExpandedRadioControl( private fun ExpandedRadioControl(
radio: SatRadio, radio: SatRadio,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState, radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit onAction: (RadarAction) -> Unit
) { ) {
@@ -425,6 +433,15 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// CW decoder panel (linear transponders only)
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
CwDecoderPanel(
cw = cw,
onAction = onAction,
modifier = Modifier.fillMaxWidth()
)
}
// Control buttons // Control buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
@@ -602,6 +619,113 @@ private fun DopplerFrequencyCalculator(
private enum class EditedField { TX, RX } private enum class EditedField { TX, RX }
@Composable
private fun CwDecoderPanel(
cw: CwSubState,
onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
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 = R.drawable.ic_arrow),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.rotate(if (cw.isExpanded) 270f else 90f)
)
}
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) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_start))
}
} else {
Button(
onClick = { onAction(RadarAction.CwStopListening) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_stop))
}
}
OutlinedButton(
onClick = { onAction(RadarAction.CwReset) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
// Signal strength indicator
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
val strengthPct = (cw.signalStrength * 100).toInt()
Text(
text = "Signal: $strengthPct%",
fontSize = 12.sp,
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 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,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let { val text = frequency?.let {