refactor(cw)!: replace legacy CW engine with DeepCW in both entry points

DeepCW 成为唯一 CW 解码内核, 不保留旧引擎作兜底 (用户决定: 完全移植)。

删除旧内核 (1298 行):
- CwDecoder.kt / CwBayesianDecoder.kt / CwChannelTracker.kt / CwSpectrogram.kt
- CwDsp / CwFFT / CwSTFFT / CwGoertzel / CwFilter / CwResampler.kt
- CwDecoderTest.kt
旧内核的自动定频与 squelch 门控由 DeepCW 的 400-1200Hz 固定频窗替代 ——
模型自带定频, 不再需要频谱峰值跟踪和噪声门。

两个 CW 入口都改接 DeepCW:
1. feature:cw 独立整页 CwDecodeScreen.kt 重写为纯 Compose (瀑布图 -> 实时行
   -> 历史区), 移除 AndroidView/LayoutInflater 和被删的 activity_main 布局;
   删除 CwSettingsDialog.kt (旧引擎的手动音调/带宽面板, DeepCW 全自动无需)
2. feature:radar 内嵌可折叠面板改走 ViewModel 的 CW state/action, 移除
   Morse Expert 控制器与 cw_panel_main 布局
   (该面板此前注释写明 "no longer feeds it", ViewModel 里的 CW 通路是死代码)

新增 CwWaterfall.kt: 复用 CwDeepSpectrogram 绘制瀑布图, 显示的正是模型
分析的 400-1200Hz 频段与同一批幅值。

接口接线:
- IMainContainer.provideCwDecoder() 提供 ICwDecoder (实现需 Context 读 assets)
- RadarViewModel 构造新增 cwDecoderFactory, onCleared() 中 close() 释放
  OrtSession 避免原生内存泄漏
- 删除已无调用方的 RadarAction.CwSetToneFreq (DeepCW 定频固定, 无参数可调);
  CwSubState.cwToneFreq 语义改为只读显示检测到的音调

依赖清理:
- feature:radar 不再依赖 feature:cw 与 constraintlayout
- feature:cw 不再依赖 constraintlayout

字符串: feature:cw 清理 Morse Expert 遗留串 (premium/rate/help/
tap_back_again_to_close/purple_500), 按 en/zh/tr/in/id 五语补全新串;
core:presentation 补 radar_cw_tone / radar_cw_waiting 五语。

验证:
./gradlew :core:domain:test => 4 个测试类全绿
./gradlew :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :feature:radar:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
This commit is contained in:
mckero committed 2026-08-12 13:00:25 +00:00
1 parent 57762613a8
commit 9b0d543f12
30 files changed
+430 -1805

No files matched your search

@@ -101,6 +101,11 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
// 面板与独立 CW 页各自持有自己的解码器
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
@@ -1,154 +0,0 @@
/*
* 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
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -1,114 +0,0 @@
/*
* 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
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -1,165 +0,0 @@
/*
* 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
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -1,102 +0,0 @@
/*
* 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()
}
}
@@ -1,87 +0,0 @@
/*
* 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.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -1,48 +0,0 @@
/*
* 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
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
*/
internal class CwFilter {
private var z1 = 0f
companion object {
private const val PI_F = 3.141592653589793f
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
@@ -1,54 +0,0 @@
/*
* 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.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -1,53 +0,0 @@
/*
* 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
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -1,61 +0,0 @@
/*
* 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.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -1,170 +0,0 @@
/*
* 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
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -42,6 +42,7 @@ interface IMainContainer {
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
fun provideSaveImage(): ISaveImage
// WaveLog logging (4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
@@ -1,301 +0,0 @@
/*
* 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', CwBayesianDecoder.morseToChar("01"))
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwBayesianDecoder.morseToChar("......."))
assertNull(CwBayesianDecoder.morseToChar(""))
}
// --- FFT ---
@Test
fun fft_magnitudeSpectrum_detectsTone() {
val fft = CwFFT(256)
val sampleRate = 8000f
val freq = 700f
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
val mag = fft.magnitudeSpectrum(buffer)
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
var maxBin = 0
var maxVal = 0f
for (i in mag.indices) {
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
}
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
}
@Test
fun fft_magnitudeSpectrum_silence_isFlat() {
val fft = CwFFT(256)
val buffer = FloatArray(256) { 0f }
val mag = fft.magnitudeSpectrum(buffer)
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
}
@Test
fun fft_rejectsWrongSize() {
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
}
// --- Spectrogram ---
@Test
fun spectrogram_addSamples_updatesEnergy() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
// Feed multiple frames to stabilize energy normalization
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val col = spec.getCurrentColumn()
val peakBin = spec.findPeakBin()
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
}
@Test
fun spectrogram_findPeakBin_returnsValidBin() {
val spec = CwSpectrogram(sampleRate = 8000)
// Add multiple frames of 700 Hz tone
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val peakBin = spec.findPeakBin()
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
}
@Test
fun spectrogram_freqToBin_roundtrip() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
val bin = spec.freqToBin(freq)
val backFreq = spec.binToFreq(bin)
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
}
@Test
fun spectrogram_getBinEnergy_returnsCorrectLength() {
val spec = CwSpectrogram(sampleRate = 8000)
val energy = spec.getBinEnergy(0, 10)
assertEquals(10, energy.size)
}
@Test
fun spectrogram_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
spec.addSamples(FloatArray(256) { 1f })
spec.reset()
assertEquals(-1, spec.findPeakBin())
}
// --- Bayesian decoder ---
@Test
fun bayesian_processTone_dit() {
val decoder = CwBayesianDecoder()
// At 20 WPM, dot = 60 ms
val result = decoder.processTone(60f)
assertEquals('0', result.symbol)
assertTrue("Dit probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_dash() {
val decoder = CwBayesianDecoder()
// Dash = 3 * dot = 180 ms
val result = decoder.processTone(180f)
assertEquals('1', result.symbol)
assertTrue("Dash probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_unknown_returnsNull() {
val decoder = CwBayesianDecoder()
// Very long tone — low probability for both dit and dash
val result = decoder.processTone(5000f)
assertNull(result.symbol)
}
@Test
fun bayesian_processGap_interChar_returnsChar() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
// 3 dots = "000" = 'S'
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
assertEquals('S', char)
}
@Test
fun bayesian_processGap_wordGap_addsSpace() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char gap
// Now word gap
val space = decoder.processGap(420f) // 7 * dot
assertEquals(' ', space)
}
@Test
fun bayesian_decodedText_accumulates() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char
assertTrue(decoder.decodedText.isNotEmpty())
}
@Test
fun bayesian_reset() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f)
decoder.reset()
assertEquals("", decoder.decodedText)
}
@Test
fun bayesian_getSpeed() {
val decoder = CwBayesianDecoder()
// Send 3 dits at 20 WPM (60 ms each)
decoder.processTone(60f)
decoder.processTone(60f)
decoder.processTone(60f)
val speed = decoder.getSpeed()
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
}
// --- Channel tracker ---
@Test
fun channelTracker_initialState() {
val spec = CwSpectrogram(sampleRate = 8000)
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("No channels should be active initially", channels.isEmpty())
}
@Test
fun channelTracker_detectsTone() {
val spec = CwSpectrogram(sampleRate = 8000)
// Feed a tone
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
}
@Test
fun channelTracker_bestChannel() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
val best = tracker.getBestChannel()
assertNotNull("Best channel should exist", best)
if (best != null) assertTrue(best.frequency in 600f..800f)
}
@Test
fun channelTracker_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
tracker.reset()
assertNull(tracker.getBestChannel())
}
// --- Full decoder ---
@Test
fun decoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_processSilence_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 0f })
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun decoder_processNoise_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_processTone_doesNotCrash() {
val decoder = CwDecoder()
for (i in 0..20) {
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_reset() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 1f })
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_withFixedPitch() {
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
assertEquals(700f, decoder.estimatedPitch.value)
}
}
@@ -132,6 +132,8 @@
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
@@ -131,6 +131,8 @@
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
@@ -296,5 +296,7 @@
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
<string name="radar_cw_tone">Ton %1$d Hz</string>
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
</resources>
@@ -123,6 +123,8 @@
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<string name="radar_cw_tone">音调 %1$d Hz</string>
<string name="radar_cw_waiting">等待信号…</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
@@ -141,6 +141,8 @@
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<string name="radar_cw_tone">Tone %1$d Hz</string>
<string name="radar_cw_waiting">Waiting for signal…</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
@@ -1,29 +1,28 @@
/*
* CwDecodeScreen.kt - Look4Sat CW decode page (Compose shell).
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* Ported from Morse Expert 1.15 com.ve3nea.morse_expert.MainActivity (converted to a plain controller class):
* - AndroidView embeds the original activity_main.xml (ConstraintLayout root, with decodedTextView/scaleView/
* statusTextView/verticalLayout/waterfallView);
* - Lifecycle onCreate/onResume/onPause/onDestroy fully delegated to the controller, same order as the original Activity
* (onCreate must run before onResume; onDispose runs onPause then onDestroy);
* - Mic permission (RECORD_AUDIO) is managed on the Compose side; onPermissionGranted() (= v()) is called once granted,
* an initialized flag guarantees it runs only once (v() news an a() which overwrites f11036E);
* - The original options_menu Clear/Pause/Save/Record/Settings became a top button row (icons from the module's original drawables).
* 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.
*
* Note: setImmersive(window, false) inside the controller's onCreate restores the host window to
* decorFitsSystemWindows(true) (ported original behavior; affects the whole host Activity).
* 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.feature.cw
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.view.LayoutInflater
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -36,169 +35,233 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.widget.ConstraintLayout
import com.ve3nea.morse_expert.MainActivity
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R as CoreR
import kotlinx.coroutines.launch
/**
* Full-page CW decoder backed by DeepCW.
*
* Layout follows the DeepCW reference app: waterfall on top, the live line
* under it, then the scrolling history. Pause/clear controls and the microphone
* permission flow carry over from the previous page.
*
* There is no settings dialog any more: the model analyses a fixed
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
*/
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val context = LocalContext.current
val activity = remember(context) {
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
}
// Ported controller (plain class, not an Activity); new instance per page entry
val controller = remember { MainActivity() }
// Inflate the original layout early so AndroidView and the controller's onCreate share one root view
val rootView = remember(context) {
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
}
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
val decoder = remember { container.provideCwDecoder() }
val audioCapture = remember { container.provideAudioCapture() }
val waterfall = remember { CwWaterfallState() }
var permissionGranted by remember {
mutableStateOf(
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
// v() news an a() which overwrites f11036E; must run exactly once
var initialized by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) }
// Lifecycle: onCreate before onResume; onDispose runs onPause then onDestroy
DisposableEffect(Unit) {
controller.onCreate(activity, rootView)
controller.onResume()
onDispose {
controller.onPause()
controller.onDestroy()
}
}
// Start the decode core after permission granted; both "already granted on entry" and "granted via dialog" go here
LaunchedEffect(permissionGranted) {
if (permissionGranted && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
// v() only creates the core; the page is already resumed, so run onResume again to start recording immediately
// (AudioRecord is freshly created so startRecording won't double-start; GLSurfaceView.onResume is idempotent)
controller.onResume()
initialized = true
}
}
val decodedText by decoder.decodedText.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val inferenceMs by decoder.lastInferenceMs.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
permanentlyDenied = !granted &&
val activity = context as? Activity
permanentlyDenied = !granted && activity != null &&
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
if (granted) isListening = true
}
// Original tap_back_again_to_close: single Back shows a hint; press again within 2 s to exit
BackHandler {
if (!controller.handleBackPress()) navigateUp()
// Capture runs only while listening; cancelling the effect stops the mic.
LaunchedEffect(isListening, permissionGranted) {
if (!isListening || !permissionGranted) return@LaunchedEffect
launch {
audioCapture.audioFlow().collect { chunk ->
decoder.processBuffer(chunk, audioCapture.sampleRate)
waterfall.pushSamples(chunk, audioCapture.sampleRate)
}
}
}
if (showSettings) {
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
LaunchedEffect(permissionGranted) {
if (permissionGranted) isListening = true
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
) {
// Original options_menu: Pause/Clear/Save (original icons) + Record/Settings (text buttons)
DisposableEffect(Unit) {
onDispose {
// OrtSession holds native memory and must be released explicitly.
decoder.close()
}
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { controller.togglePause() }) {
IconButton(onClick = { isListening = false; navigateUp() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_pause_24),
contentDescription = stringResource(R.string.pause)
painter = painterResource(CoreR.drawable.ic_back),
contentDescription = stringResource(R.string.cw_back)
)
}
IconButton(onClick = { controller.clearDecoded() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_delete_24),
contentDescription = stringResource(R.string.clear)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(CoreR.string.nav_cw),
style = MaterialTheme.typography.titleMedium
)
Text(
text = if (estimatedPitch != null) {
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
} else {
stringResource(R.string.cw_status_listening)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { controller.saveText() }) {
IconButton(onClick = { isListening = !isListening }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_save_24),
contentDescription = stringResource(R.string.save_text)
painter = painterResource(
if (isListening) CoreR.drawable.ic_pause else CoreR.drawable.ic_play
),
contentDescription = stringResource(
if (isListening) R.string.cw_pause else R.string.cw_resume
)
)
}
// recordSignals needs the decode core (f11037F); disabled until initialized
TextButton(
onClick = { if (initialized) controller.recordSignals() },
enabled = initialized
) {
Text(stringResource(R.string.record_signals))
}
TextButton(onClick = { showSettings = true }) {
Text(stringResource(R.string.settings))
IconButton(onClick = { decoder.reset(); waterfall.clear() }) {
Icon(
painter = painterResource(CoreR.drawable.ic_delete),
contentDescription = stringResource(R.string.cw_clear)
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(8.dp))
) {
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
}
Text(
text = decodedText.takeLast(64).ifEmpty { "…" },
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = decodedText,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
if (!permissionGranted) {
// Mic permission not granted: overlay + hint text + request button
errorMessage?.let { message ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x99000000)),
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
if (!permissionGranted) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.cw_mic_permission),
color = Color.White,
color = MaterialTheme.colorScheme.inverseOnSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) {
Text(stringResource(R.string.cw_grant_permission))
}
if (permanentlyDenied) {
// "Never ask again" checked: guide user to system settings
TextButton(onClick = {
activity.startActivity(
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${activity.packageName}")
Uri.fromParts("package", context.packageName, null)
)
)
}) {
@@ -209,5 +272,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@@ -1,218 +0,0 @@
/*
* 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.
*/
package com.rtbishop.look4sat.feature.cw
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt
/** Keys/defaults ported from the original Morse Expert (root_preferences.xml + SettingsActivity logic). */
private const val KEY_MESSAGE_TYPE = "message_type"
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
private const val DEFAULT_MESSAGE_TYPE = "general_text"
private const val DEFAULT_TEXT_FONT_SIZE = 18
private const val MIN_FONT_SIZE = 7
private const val MAX_FONT_SIZE = 99
/** Preset palette (9 colors, 3x3 grid); the original ColorPreferenceCompat used a third-party colorpicker, not imported. */
private val PaletteColors = listOf(
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
)
/**
* CW settings dialog (replaces the original Morse Expert SettingsActivity).
* Items and keys ported from the original APK: message_type / text_font_size / 9 colors
* (I2.b.f663b keys, I2.b.f664d defaults, I2.b.c English titles).
* Storage: SharedPreferences(getPackageName() + "_preferences").
* OK saves and calls controller.onResume() to take effect at once; Cancel/outside-tap only closes without saving.
*/
@Composable
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
val activity = controller.mActivity ?: return
val prefs = remember(activity) {
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
}
var messageType by remember {
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
}
var fontSize by remember {
mutableStateOf(
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
)
}
var colorValues by remember {
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("CW Settings") },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(top = 4.dp)
) {
Text("Message type", style = MaterialTheme.typography.titleSmall)
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Text font size", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = fontSize,
onValueChange = { fontSize = it },
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
modifier = Modifier.weight(1f),
)
Text(
text = "${fontSize.roundToInt()}",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.width(44.dp),
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Colors", style = MaterialTheme.typography.titleSmall)
for (i in 0 until 9) {
ColorSettingRow(
title = I2.b.c[i],
value = colorValues[i],
onSelect = { selected ->
colorValues = colorValues.copyOf().also { it[i] = selected }
},
)
}
}
},
confirmButton = {
TextButton(onClick = {
val editor = prefs.edit()
editor.putString(KEY_MESSAGE_TYPE, messageType)
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
for (i in 0 until 9) {
editor.putInt(I2.b.f663b[i], colorValues[i])
}
editor.apply()
// The original re-read all settings in MainActivity.onResume() when SettingsActivity returned; ported logic in place
controller.onResume()
onDismiss()
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { onSelect(option) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Box(
Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(value)),
)
Spacer(Modifier.width(6.dp))
Text(
text = String.format("#%06X", value and 0xFFFFFF),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(6.dp))
PaletteGrid(selected = value, onSelect = onSelect)
}
}
@Composable
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
PaletteColors.chunked(3).forEach { rowColors ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rowColors.forEach { color ->
val isSelected = color == selected
Box(
modifier = Modifier
.size(36.dp)
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(6.dp),
)
.clip(RoundedCornerShape(6.dp))
.background(Color(color))
.clickable { onSelect(color) },
)
}
}
}
}
}
@@ -0,0 +1,135 @@
/*
* 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.feature.cw
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
/**
* Rolling spectrogram history for the waterfall display.
*
* Reuses [CwDeepSpectrogram] so the picture shows exactly the 400-1200 Hz band
* and the same magnitudes the model sees — what you look at is what it decodes.
*/
class CwWaterfallState(private val historyRows: Int = 96) {
private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
/** Bumped on every change so Compose knows to redraw. */
val revision = mutableIntStateOf(0)
/** Snapshot for drawing, oldest row first. */
fun snapshot(): List<FloatArray> = rows.toList()
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
if (chunk.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
pending.ensureCapacity(pending.size + resampled.size)
for (sample in resampled) pending.add(sample)
// Need at least one FFT window before a row can be produced.
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
val audio = FloatArray(pending.size) { pending[it] }
pending.clear()
for (row in CwDeepSpectrogram.compute(audio)) {
if (rows.size >= historyRows) rows.removeFirst()
rows.addLast(row)
}
revision.intValue++
}
fun clear() {
rows.clear()
pending.clear()
revision.intValue++
}
}
/**
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp goes dark blue -> cyan -> yellow with magnitude.
*/
@Composable
internal fun CwWaterfallView(
state: CwWaterfallState,
signalStrength: Float,
modifier: Modifier = Modifier
) {
// Reading the revision inside the draw scope is what triggers redraws as
// new spectra arrive; without it the canvas would render only once.
val revision = state.revision.intValue
Canvas(modifier = modifier.fillMaxSize()) {
drawRect(color = Color(0xFF00060F), size = size)
val rows = state.snapshot()
if (rows.isEmpty()) return@Canvas
// Scale to the loudest value on screen so quiet signals stay visible.
var peak = 0f
for (row in rows) for (v in row) if (v > peak) peak = v
if (peak <= 0f) return@Canvas
val rowHeight = size.height / rows.size
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
for (bin in row.indices) {
val magnitude = (row[bin] / peak).coerceIn(0f, 1f)
if (magnitude < 0.06f) continue
drawRect(
color = rampColor(magnitude),
topLeft = Offset(bin * binWidth, y),
size = Size(binWidth + 1f, rowHeight + 1f)
)
}
}
if (signalStrength > 0f) {
drawRect(
color = Color(0xFF4CD964).copy(alpha = 0.8f),
topLeft = Offset(0f, size.height - 3f),
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
)
}
}
}
private fun rampColor(magnitude: Float): Color = when {
magnitude < 0.5f -> {
val t = magnitude / 0.5f
Color(red = 0f, green = 0.35f * t, blue = 0.35f + 0.55f * t)
}
else -> {
val t = (magnitude - 0.5f) / 0.5f
Color(red = t, green = 0.35f + 0.6f * t, blue = 0.9f - 0.8f * t)
}
}
@@ -1,22 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Diam</string>
<string name="waterfall_display">Tampilan waterfall</string>
<string name="frequency_scale">Skala frekuensi</string>
<string name="decoded_text">Teks terdekode</string>
<string name="clear">Bersihkan</string>
<string name="pause">Jeda Dekode</string>
<string name="save_text">Simpan Teks</string>
<string name="record_signals">Rekam Sinyal</string>
<string name="settings">Pengaturan</string>
<string name="help">Bantuan Daring</string>
<string name="rate">Nilai Aplikasi Ini</string>
<string name="premium">Dapatkan Versi Premium</string>
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
<string name="message_type">Tipe Pesan</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
<string name="cw_grant_permission">Beri izin</string>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -1,22 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Diam</string>
<string name="waterfall_display">Tampilan waterfall</string>
<string name="frequency_scale">Skala frekuensi</string>
<string name="decoded_text">Teks terdekode</string>
<string name="clear">Bersihkan</string>
<string name="pause">Jeda Dekode</string>
<string name="save_text">Simpan Teks</string>
<string name="record_signals">Rekam Sinyal</string>
<string name="settings">Pengaturan</string>
<string name="help">Bantuan Daring</string>
<string name="rate">Nilai Aplikasi Ini</string>
<string name="premium">Dapatkan Versi Premium</string>
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
<string name="message_type">Tipe Pesan</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
<string name="cw_grant_permission">Beri izin</string>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -1,22 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Beklemede</string>
<string name="waterfall_display">Şelale görüntüsü</string>
<string name="frequency_scale">Frekans ölçeği</string>
<string name="decoded_text">Çözülen metin</string>
<string name="clear">Temizle</string>
<string name="pause">Çözmeyi Duraklat</string>
<string name="save_text">Metni Kaydet</string>
<string name="record_signals">Sinyalleri Kaydet</string>
<string name="settings">Ayarlar</string>
<string name="help">Çevrimiçi Yardım</string>
<string name="rate">Bu Uygulamayı Değerlendir</string>
<string name="premium">Premium Sürümü Al</string>
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
<string name="message_type">Mesaj Türü</string>
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
<string name="cw_back">Geri</string>
<string name="cw_pause">Çözmeyi duraklat</string>
<string name="cw_resume">Çözmeyi sürdür</string>
<string name="cw_clear">Çözülen metni temizle</string>
<string name="cw_status_listening">Dinleniyor…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
</resources>
@@ -1,21 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">空闲</string>
<string name="waterfall_display">瀑布图</string>
<string name="frequency_scale">频率刻度</string>
<string name="decoded_text">解码文本</string>
<string name="clear">清除</string>
<string name="pause">暂停解码</string>
<string name="save_text">保存文本</string>
<string name="record_signals">录音信号</string>
<string name="settings">设置</string>
<string name="help">在线帮助</string>
<string name="rate">评价此应用</string>
<string name="premium">获取高级版</string>
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
<string name="message_type">消息类型</string>
<string name="cw_back">返回</string>
<string name="cw_pause">暂停解码</string>
<string name="cw_resume">继续解码</string>
<string name="cw_clear">清空解码文本</string>
<string name="cw_status_listening">正在监听…</string>
<string name="cw_status_tone">%1$d Hz · %2$d 毫秒</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
+6 -16
View File
@@ -1,21 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Idle</string>
<string name="waterfall_display">Waterfall display</string>
<string name="frequency_scale">Frequency scale</string>
<string name="decoded_text">Decoded text</string>
<string name="clear">Clear</string>
<string name="pause">Pause Decoding</string>
<string name="save_text">Save Text</string>
<string name="record_signals">Record Signals</string>
<string name="settings">Settings</string>
<string name="help">Online Help</string>
<string name="rate">Rate this App</string>
<string name="premium">Get Premium Version</string>
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
<string name="message_type">Message Type</string>
<string name="cw_back">Back</string>
<string name="cw_pause">Pause decoding</string>
<string name="cw_resume">Resume decoding</string>
<string name="cw_clear">Clear decoded text</string>
<string name="cw_status_listening">Listening…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
+3 -5
View File
@@ -8,8 +8,6 @@ android {
dependencies {
implementation(project(":feature:mutual"))
// CW 解码面板: 复用 feature:cw 的 Morse Expert 引擎(布局 cw_panel_main + MainActivity 控制器)
implementation(project(":feature:cw"))
// 与 feature:cw 一致(控制器按 ID 递归查找 ConstraintLayout 视图)
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
// CW 内嵌面板走 ICwDecoder 接口 + ViewModel state, 实现由 MainContainer 注入,
// 无需依赖 feature:cw 或 constraintlayout (旧 Morse Expert 布局已删除)
}
@@ -92,6 +92,7 @@ data class CwSubState(
val status: CwStatus = CwStatus.Idle,
val hasPermission: Boolean = false,
val decodedText: String = "",
/** Tone detected by the decoder, read-only: DeepCW analyses a fixed 400-1200 Hz window. */
val cwToneFreq: Float = 700f,
val isExpanded: Boolean = false,
val signalStrength: Float = 0f
@@ -121,7 +122,6 @@ sealed interface RadarAction {
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,7 +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.cw.ICwDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
@@ -62,6 +62,7 @@ class RadarViewModel(
private val addToCalendar: IAddToCalendar,
private val trackingService: IRadioTrackingService,
private val audioCapture: IAudioCapture,
private val cwDecoderFactory: () -> ICwDecoder,
private val saveImage: ISaveImage,
private val showToast: IShowToast
) : ViewModel() {
@@ -71,7 +72,7 @@ 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 cwDecoder: ICwDecoder? = null
private var cwListeningJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute
@@ -219,6 +220,9 @@ class RadarViewModel(
override fun onCleared() {
sensorsRepo.disableSensor()
// OrtSession holds native memory; it must be released explicitly.
cwDecoder?.close()
cwDecoder = null
}
fun onAction(action: RadarAction) {
@@ -286,13 +290,9 @@ class RadarViewModel(
RadarAction.CwStartListening -> startCwListening()
RadarAction.CwStopListening -> stopCwListening()
RadarAction.CwReset -> {
cwDecoder?.resetDecoder()
cwDecoder?.reset()
_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)) }
}
@@ -417,12 +417,7 @@ class RadarViewModel(
}
private fun initCwDecoder() {
if (cwDecoder == null) {
cwDecoder = CwDecoder(
sampleRate = audioCapture.sampleRate,
cwToneFreq = _uiState.value.cw.cwToneFreq
)
}
if (cwDecoder == null) cwDecoder = cwDecoderFactory()
}
private fun startCwListening() {
@@ -434,24 +429,33 @@ class RadarViewModel(
// Stop SSTV if running (audio capture is shared)
stopSstvRecording()
initCwDecoder()
cwDecoder?.resetDecoder()
cwDecoder?.reset()
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Listening)) }
cwListeningJob = viewModelScope.launch {
// Collect decoded text flow
val decoder = cwDecoder ?: return@launch
// decodedText is replace semantics: DeepCW revises earlier characters
// as more context arrives, so mirror the value rather than appending.
launch {
cwDecoder?.decodedTextFlow?.collect { text ->
decoder.decodedText.collect { text ->
_uiState.update { it.copy(cw = it.cw.copy(decodedText = text)) }
}
}
// Collect signal strength
launch {
cwDecoder?.signalStrength?.collect { strength ->
decoder.signalStrength.collect { strength ->
_uiState.update { it.copy(cw = it.cw.copy(signalStrength = strength)) }
}
}
// Capture audio and feed to decoder
// Detected tone, shown read-only: the model analyses a fixed
// 400-1200 Hz window, so there is no pitch to configure.
launch {
decoder.estimatedPitch.collect { pitch ->
if (pitch != null) {
_uiState.update { it.copy(cw = it.cw.copy(cwToneFreq = pitch)) }
}
}
}
audioCapture.audioFlow().collect { buffer ->
cwDecoder?.processBuffer(buffer)
decoder.processBuffer(buffer, audioCapture.sampleRate)
}
}
}
@@ -516,6 +520,7 @@ class RadarViewModel(
addToCalendar = container.provideAddToCalendar(),
trackingService = container.radioTrackingService,
audioCapture = container.provideAudioCapture(),
cwDecoderFactory = { container.provideCwDecoder() },
saveImage = container.provideSaveImage(),
showToast = container.provideShowToast()
)
@@ -17,14 +17,16 @@
*/
package com.rtbishop.look4sat.feature.radar
import android.app.Activity
import android.view.LayoutInflater
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -71,8 +73,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
@@ -80,8 +80,6 @@ import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.feature.cw.R as CwR
import com.ve3nea.morse_expert.MainActivity
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
@@ -714,85 +712,70 @@ private fun CwDecoderPanel(
}
AnimatedVisibility(visible = cw.isExpanded) {
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current
val activity = remember { context as? Activity }
val controller = remember { MainActivity() }
val rootView = remember {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout
}
var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
if (activity != null) {
controller.onCreate(activity, rootView, false)
}
onDispose {
controller.onPause()
controller.onDestroy()
}
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
}
}
// DeepCW engine, driven through the ViewModel. Decoded text uses
// replace semantics: the model revises earlier characters as more
// audio arrives, so show the current value instead of appending.
val listening = cw.status == CwStatus.Listening
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (!listening) {
Button(
onClick = {
if (!cw.hasPermission) {
requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else {
controller.onResume()
listening = true
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_start))
}
} else {
Button(
onClick = {
controller.onPause()
listening = false
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_stop))
}
Button(
onClick = {
when {
!cw.hasPermission -> requestMicPermission()
listening -> onAction(RadarAction.CwStopListening)
else -> onAction(RadarAction.CwStartListening)
}
},
modifier = Modifier.weight(1f)
) {
Text(
stringResource(
if (listening) R.string.radar_cw_stop else R.string.radar_cw_start
)
)
}
OutlinedButton(
onClick = { controller.clearDecoded() },
onClick = { onAction(RadarAction.CwReset) },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
AndroidView(
factory = { rootView },
// Detected tone is read-only: the model analyses a fixed
// 400-1200 Hz window, so there is no pitch to configure.
if (listening) {
Text(
text = stringResource(R.string.radar_cw_tone, cw.cwToneFreq.toInt()),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = cw.decodedText.ifEmpty {
stringResource(R.string.radar_cw_waiting)
},
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}