diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
index 797b44a2..9236cfbe 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
@@ -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)
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwBayesianDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwBayesianDecoder.kt
deleted file mode 100644
index c86bedff..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwBayesianDecoder.kt
+++ /dev/null
@@ -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 .
- */
-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()
-
- // 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)
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwChannelTracker.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwChannelTracker.kt
deleted file mode 100644
index 9e324d0d..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwChannelTracker.kt
+++ /dev/null
@@ -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 .
- */
-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 = 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 {
- 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 {
- val peaks = mutableListOf()
- 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 {
- 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)
- }
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt
deleted file mode 100644
index b358846a..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDecoder.kt
+++ /dev/null
@@ -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 .
- */
-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 = _decodedTextFlow
-
- private val _signalStrength = MutableStateFlow(0f)
- val signalStrength: StateFlow = _signalStrength
-
- private val _estimatedPitch = MutableStateFlow(null)
- val estimatedPitch: StateFlow = _estimatedPitch
-
- private val _estimatedSpeed = MutableStateFlow(null)
- val estimatedSpeed: StateFlow = _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
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt
deleted file mode 100644
index 07700e10..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDsp.kt
+++ /dev/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 .
- */
-package com.rtbishop.look4sat.core.domain.cw
-
-import kotlin.math.PI
-import kotlin.math.cos
-import kotlin.math.sin
-import kotlin.math.sqrt
-
-/**
- * DSP utilities for CW (Morse code) decoding.
- * Pure Kotlin, no NDK required.
- */
-internal object CwDsp {
-
- /**
- * Design a simple bandpass FIR filter coefficients using windowed sinc method.
- * @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
- * @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
- * @param taps filter length (must be odd)
- */
- fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
- val n = if (taps % 2 == 0) taps + 1 else taps
- val half = n / 2
- val coeffs = FloatArray(n)
- for (i in 0 until n) {
- val idx = i - half
- if (idx == 0) {
- coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
- } else {
- val x = PI * idx
- coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
- }
- // Hamming window
- coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
- }
- // Normalize
- val sum = coeffs.sum()
- if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
- return coeffs
- }
-
- /** Apply FIR filter to a buffer. */
- fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
- val out = FloatArray(buffer.size)
- for (i in buffer.indices) {
- var sum = 0f
- for (j in coeffs.indices) {
- val idx = i - j
- if (idx >= 0) sum += buffer[idx] * coeffs[j]
- }
- out[i] = sum
- }
- return out
- }
-
- /** Simple envelope detector: abs + low-pass smoothing. */
- fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
- val env = FloatArray(signal.size)
- var s = 0f
- for (i in signal.indices) {
- s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
- env[i] = s
- }
- return env
- }
-
- /** Estimate noise floor from envelope for adaptive thresholding. */
- fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
- val sorted = env.sortedArray()
- val median = sorted[sorted.size / 2]
- return median + (sorted[sorted.size * 9 / 10] - median) * fraction
- }
-
- /** Simple Goertzel to detect a specific tone frequency. */
- fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
- val omega = 2.0 * PI * targetFreq / sampleRate
- val coeff = 2.0 * cos(omega)
- var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
- for (sample in buffer) {
- s0 = sample.toDouble() + coeff * s1 - s2
- s2 = s1; s1 = s0
- }
- val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
- return sqrt(kotlin.math.abs(power)).toFloat()
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFFT.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFFT.kt
deleted file mode 100644
index 0a0ef57f..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFFT.kt
+++ /dev/null
@@ -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 .
- */
-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
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFilter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFilter.kt
deleted file mode 100644
index 5715b1c2..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwFilter.kt
+++ /dev/null
@@ -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 .
- */
-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 }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwGoertzel.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwGoertzel.kt
deleted file mode 100644
index eb771345..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwGoertzel.kt
+++ /dev/null
@@ -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 .
- */
-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
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwResampler.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwResampler.kt
deleted file mode 100644
index 954acdb9..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwResampler.kt
+++ /dev/null
@@ -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 .
- */
-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
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSTFFT.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSTFFT.kt
deleted file mode 100644
index de5122e7..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSTFFT.kt
+++ /dev/null
@@ -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 .
- */
-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
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt
deleted file mode 100644
index fa5676b1..00000000
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwSpectrogram.kt
+++ /dev/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 .
- */
-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 {
- 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)
- }
-}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
index bef07d61..2d165d45 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
@@ -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
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt
deleted file mode 100644
index 815acb99..00000000
--- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDecoderTest.kt
+++ /dev/null
@@ -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 .
- */
-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)
- }
-}
\ No newline at end of file
diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml
index e7a1ef22..d0fca3d4 100644
--- a/core/presentation/src/main/res/values-id/strings.xml
+++ b/core/presentation/src/main/res/values-id/strings.xml
@@ -132,6 +132,8 @@
Mulai
Berhenti
Bersihkan
+ Nada %1$d Hz
+ Menunggu sinyal…
Sebelumnya
Berikutnya
© Kontributor OpenStreetMap
diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml
index f5dc4c16..14ed6bf6 100644
--- a/core/presentation/src/main/res/values-in/strings.xml
+++ b/core/presentation/src/main/res/values-in/strings.xml
@@ -131,6 +131,8 @@
Mulai
Berhenti
Bersihkan
+ Nada %1$d Hz
+ Menunggu sinyal…
Sebelumnya
Berikutnya
© Kontributor OpenStreetMap
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index 1140df02..3e7c0165 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -296,5 +296,7 @@
\n• BA7OPF (pass matching feature)
\n• BG7NTA
Bu uygulama herhangi bir garanti sunmaz
+ Ton %1$d Hz
+ Sinyal bekleniyor…
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 82d4b412..2ad0fec9 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -123,6 +123,8 @@
开始
停止
清空
+ 音调 %1$d Hz
+ 等待信号…
上一个
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 75fdb622..c2fc5b61 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -141,6 +141,8 @@
Start
Stop
Clear
+ Tone %1$d Hz
+ Waiting for signal…
Prev
diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt
index 7ad77763..c670a18b 100644
--- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt
+++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwDecodeScreen.kt
@@ -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 .
*/
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))
}
}
diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt
deleted file mode 100644
index ca3f30de..00000000
--- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt
+++ /dev/null
@@ -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) },
- )
- }
- }
- }
- }
-}
diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt
new file mode 100644
index 00000000..5886e0e3
--- /dev/null
+++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwWaterfall.kt
@@ -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 .
+ */
+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(historyRows)
+ private val pending = ArrayList(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 = 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)
+ }
+}
diff --git a/feature/cw/src/main/res/values-id/app_values.xml b/feature/cw/src/main/res/values-id/app_values.xml
index 19601995..79ffa13c 100644
--- a/feature/cw/src/main/res/values-id/app_values.xml
+++ b/feature/cw/src/main/res/values-id/app_values.xml
@@ -1,22 +1,12 @@
- #ff6200ee
- 18
- Diam
- Tampilan waterfall
- Skala frekuensi
- Teks terdekode
- Bersihkan
- Jeda Dekode
- Simpan Teks
- Rekam Sinyal
- Pengaturan
- Bantuan Daring
- Nilai Aplikasi Ini
- Dapatkan Versi Premium
- Ketuk Kembali lagi untuk menutup aplikasi
- Tipe Pesan
- Izin mikrofon diperlukan untuk dekode CW
- Beri izin
+ Kembali
+ Jeda pendekodean
+ Lanjutkan pendekodean
+ Hapus teks hasil dekode
+ Mendengarkan…
+ %1$d Hz · %2$d ms
+ Izin mikrofon diperlukan untuk pendekodean CW
+ Berikan izin
Buka pengaturan aplikasi
diff --git a/feature/cw/src/main/res/values-in/app_values.xml b/feature/cw/src/main/res/values-in/app_values.xml
index 19601995..79ffa13c 100644
--- a/feature/cw/src/main/res/values-in/app_values.xml
+++ b/feature/cw/src/main/res/values-in/app_values.xml
@@ -1,22 +1,12 @@
- #ff6200ee
- 18
- Diam
- Tampilan waterfall
- Skala frekuensi
- Teks terdekode
- Bersihkan
- Jeda Dekode
- Simpan Teks
- Rekam Sinyal
- Pengaturan
- Bantuan Daring
- Nilai Aplikasi Ini
- Dapatkan Versi Premium
- Ketuk Kembali lagi untuk menutup aplikasi
- Tipe Pesan
- Izin mikrofon diperlukan untuk dekode CW
- Beri izin
+ Kembali
+ Jeda pendekodean
+ Lanjutkan pendekodean
+ Hapus teks hasil dekode
+ Mendengarkan…
+ %1$d Hz · %2$d ms
+ Izin mikrofon diperlukan untuk pendekodean CW
+ Berikan izin
Buka pengaturan aplikasi
diff --git a/feature/cw/src/main/res/values-tr/app_values.xml b/feature/cw/src/main/res/values-tr/app_values.xml
index a5d91c08..5c603610 100644
--- a/feature/cw/src/main/res/values-tr/app_values.xml
+++ b/feature/cw/src/main/res/values-tr/app_values.xml
@@ -1,22 +1,12 @@
- #ff6200ee
- 18
- Beklemede
- Şelale görüntüsü
- Frekans ölçeği
- Çözülen metin
- Temizle
- Çözmeyi Duraklat
- Metni Kaydet
- Sinyalleri Kaydet
- Ayarlar
- Çevrimiçi Yardım
- Bu Uygulamayı Değerlendir
- Premium Sürümü Al
- Kapatmak için Geriye tekrar dokunun
- Mesaj Türü
- CW çözme için mikrofon izni gereklidir
+ Geri
+ Çözmeyi duraklat
+ Çözmeyi sürdür
+ Çözülen metni temizle
+ Dinleniyor…
+ %1$d Hz · %2$d ms
+ CW çözümü için mikrofon izni gerekli
İzin ver
Uygulama ayarlarını aç
diff --git a/feature/cw/src/main/res/values-zh/app_values.xml b/feature/cw/src/main/res/values-zh/app_values.xml
index acf5972c..4039395c 100644
--- a/feature/cw/src/main/res/values-zh/app_values.xml
+++ b/feature/cw/src/main/res/values-zh/app_values.xml
@@ -1,21 +1,11 @@
- #ff6200ee
- 18
- 空闲
- 瀑布图
- 频率刻度
- 解码文本
- 清除
- 暂停解码
- 保存文本
- 录音信号
- 设置
- 在线帮助
- 评价此应用
- 获取高级版
- 再次按返回键退出应用
- 消息类型
+ 返回
+ 暂停解码
+ 继续解码
+ 清空解码文本
+ 正在监听…
+ %1$d Hz · %2$d 毫秒
CW 解码需要麦克风权限
授予权限
打开应用设置
diff --git a/feature/cw/src/main/res/values/app_values.xml b/feature/cw/src/main/res/values/app_values.xml
index 83e4e89b..fcadbd43 100644
--- a/feature/cw/src/main/res/values/app_values.xml
+++ b/feature/cw/src/main/res/values/app_values.xml
@@ -1,21 +1,11 @@
- #ff6200ee
- 18
- Idle
- Waterfall display
- Frequency scale
- Decoded text
- Clear
- Pause Decoding
- Save Text
- Record Signals
- Settings
- Online Help
- Rate this App
- Get Premium Version
- Tap Back again to close the app
- Message Type
+ Back
+ Pause decoding
+ Resume decoding
+ Clear decoded text
+ Listening…
+ %1$d Hz · %2$d ms
Microphone permission is required for CW decoding
Grant permission
Open app settings
diff --git a/feature/radar/build.gradle.kts b/feature/radar/build.gradle.kts
index 9125bb22..b97913d3 100644
--- a/feature/radar/build.gradle.kts
+++ b/feature/radar/build.gradle.kts
@@ -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")
-}
\ No newline at end of file
+ // CW 内嵌面板走 ICwDecoder 接口 + ViewModel state, 实现由 MainContainer 注入,
+ // 无需依赖 feature:cw 或 constraintlayout (旧 Morse Expert 布局已删除)
+}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
index 5f747026..aa3b757b 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt
@@ -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
}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
index 2b0fe4b8..f11ca8df 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt
@@ -34,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 = 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()
)
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
index f4af915b..4ec92cee 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt
@@ -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
+ )
+ }
}
}
}