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

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

No files matched your search

@@ -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) },
@@ -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 {