From 33712c29fcb63a5f08b38f592ce9e2d5eebf75e5 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 31 Jul 2026 18:44:49 +0800 Subject: [PATCH] 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 --- ...-07-31_113800-cw-decoder-implementation.md | 781 ++++++++++++++++++ .../look4sat/core/domain/cw/CwDecoder.kt | 178 ++++ .../rtbishop/look4sat/core/domain/cw/CwDsp.kt | 102 +++ .../look4sat/core/domain/cw/CwDecoderTest.kt | 232 ++++++ .../src/main/res/values/strings.xml | 5 + .../look4sat/feature/radar/RadarScreen.kt | 13 +- .../look4sat/feature/radar/RadarState.kt | 24 +- .../look4sat/feature/radar/RadarViewModel.kt | 64 ++ .../feature/radar/TransceiversPage.kt | 124 +++ 9 files changed, 1516 insertions(+), 7 deletions(-) create mode 100644 .hermes/plans/2026-07-31_113800-cw-decoder-implementation.md create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt diff --git a/.hermes/plans/2026-07-31_113800-cw-decoder-implementation.md b/.hermes/plans/2026-07-31_113800-cw-decoder-implementation.md new file mode 100644 index 00000000..8228209d --- /dev/null +++ b/.hermes/plans/2026-07-31_113800-cw-decoder-implementation.md @@ -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` 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 = _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 +CW Decoder +Start Listening +Stop +Clear +Tone (Hz) +CW Decoder +``` + +**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. \ No newline at end of file diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt new file mode 100644 index 00000000..e0db224b --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt @@ -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 . + */ +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 = _decodedTextFlow + + private val _signalStrength = MutableStateFlow(0f) + val signalStrength: StateFlow = _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] + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt new file mode 100644 index 00000000..07700e10 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt @@ -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 . + */ +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() + } +} \ No newline at end of file diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt new file mode 100644 index 00000000..118b71b9 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt @@ -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 . + */ +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) + } +} \ No newline at end of file diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index a8080c3e..9a25c2c1 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -86,6 +86,11 @@ Offset (kHz) For linear transponders, type one frequency to see the other + CW Decoder + Start + Stop + Clear + Prev Next diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index ae368c9f..6ae724a7 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -171,12 +171,13 @@ private fun PagerCard( ) { pageIndex -> when (pages[pageIndex]) { RadarPage.Transceivers -> TransceiversPage( - transceivers = uiState.transceivers.transmitters, - selectedUuid = uiState.transceivers.selectedUuid, - orbitalPos = uiState.orbitalPos, - radioControl = uiState.radioControl, - onAction = onAction - ) + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, + orbitalPos = uiState.orbitalPos, + cw = uiState.cw, + radioControl = uiState.radioControl, + onAction = onAction + ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index bb5a0329..d668670f 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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 } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index c013022f..a0ad434d 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -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 = 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 // ============================== diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index e1e906a6..08194fca 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -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, 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 {