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:
1 parent
57762613a8
commit
9b0d543f12
30 files changed
+430
-1805
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user