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 + ) + } } } }