mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
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_info">For linear transponders, type one frequency to see the other</string>
|
||||
|
||||
<string name="radar_cw_decoder">CW Decoder</string>
|
||||
<string name="radar_cw_start">Start</string>
|
||||
<string name="radar_cw_stop">Stop</string>
|
||||
<string name="radar_cw_reset">Clear</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
|
||||
@@ -174,6 +174,7 @@ private fun PagerCard(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
|
||||
@@ -62,7 +62,8 @@ data class RadarState(
|
||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||
val sstv: SstvSubState = SstvSubState()
|
||||
val sstv: SstvSubState = SstvSubState(),
|
||||
val cw: CwSubState = CwSubState()
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -77,6 +78,19 @@ data class SstvSubState(
|
||||
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 {
|
||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||
@@ -96,4 +110,12 @@ sealed interface RadarAction {
|
||||
data object SstvReset : RadarAction
|
||||
data class SstvSelectMode(val modeName: String) : 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.sstv.LineRecoveryStrategy
|
||||
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.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
@@ -70,6 +71,8 @@ class RadarViewModel(
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var sstvDecoder: SstvDecoder? = 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
|
||||
private var lastCelestialUpdateMs = 0L
|
||||
@@ -272,6 +275,25 @@ class RadarViewModel(
|
||||
sstvDecoder?.clearPixels()
|
||||
_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)) }
|
||||
}
|
||||
|
||||
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 {
|
||||
// SSTV Decoder Tuning Parameters
|
||||
// ==============================
|
||||
|
||||
@@ -40,10 +40,13 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -80,6 +83,7 @@ fun TransceiversPage(
|
||||
transceivers: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -105,6 +109,7 @@ fun TransceiversPage(
|
||||
radio = radio,
|
||||
isExpanded = isExpanded,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction,
|
||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||
@@ -142,6 +147,7 @@ private fun TransceiverItem(
|
||||
radio: SatRadio,
|
||||
isExpanded: Boolean,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
onToggle: () -> Unit
|
||||
@@ -237,6 +243,7 @@ private fun TransceiverItem(
|
||||
ExpandedRadioControl(
|
||||
radio = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
cw = cw,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
@@ -308,6 +315,7 @@ private fun UnifiedFrequencyRow(
|
||||
private fun ExpandedRadioControl(
|
||||
radio: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
@@ -425,6 +433,15 @@ private fun ExpandedRadioControl(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// CW decoder panel (linear transponders only)
|
||||
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
||||
CwDecoderPanel(
|
||||
cw = cw,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||
@@ -602,6 +619,113 @@ private fun DopplerFrequencyCalculator(
|
||||
|
||||
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
|
||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||
val text = frequency?.let {
|
||||
|
||||
Reference in new issue
Block a user