diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt new file mode 100644 index 00000000..0631632c --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt @@ -0,0 +1,212 @@ +/* + * 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.data.cw + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import android.content.Context +import android.util.Log +import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder +import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer +import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram +import com.rtbishop.look4sat.core.domain.cw.ICwDecoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.nio.FloatBuffer + +/** + * CW decoder backed by the DeepCW neural network (AGPL-3.0, see + * `feature/cw/licenses/NOTICE.md`). + * + * The model classifies a whole audio segment at once rather than streaming + * sample by sample, and it revises earlier characters once more context + * arrives. Incremental stitching therefore produces duplicated callsigns — + * measured character error rates of 67-294% against 0% for whole-segment + * decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole + * window is re-decoded every 1.5 seconds, replacing [decodedText] outright. + * + * The model's fixed 400-1200 Hz analysis window means pitch detection is built + * in; no spectral peak tracking or squelch gating is needed. + */ +class CwDeepDecoder(context: Context) : ICwDecoder { + + private companion object { + const val TAG = "CwDeepDecoder" + const val MODEL_ASSET = "deepcw/model.onnx" + const val METADATA_ASSET = "deepcw/model.onnx.json" + } + + private val _decodedText = MutableStateFlow("") + override val decodedText: StateFlow = _decodedText.asStateFlow() + + private val _estimatedPitch = MutableStateFlow(null) + override val estimatedPitch: StateFlow = _estimatedPitch.asStateFlow() + + private val _signalStrength = MutableStateFlow(0f) + override val signalStrength: StateFlow = _signalStrength.asStateFlow() + + private val _lastInferenceMs = MutableStateFlow(0) + override val lastInferenceMs: StateFlow = _lastInferenceMs.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + override val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val buffer = CwDeepBuffer() + + /** Held while inference runs so slow devices skip work instead of queuing it. */ + private val inferenceLock = Mutex() + + private var environment: OrtEnvironment? = null + private var session: OrtSession? = null + private var chars: List = emptyList() + private var blankIndex = 41 + private var inputName = "spectrogram" + private var outputName = "log_probs" + + init { + try { + val metadata = JSONObject( + context.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() } + ) + val charArray = metadata.getJSONArray("chars") + chars = List(charArray.length()) { charArray.getString(it) } + blankIndex = metadata.getInt("blank_index") + inputName = metadata.getString("onnx_input_name") + outputName = metadata.getString("onnx_output_name") + + val modelBytes = context.assets.open(MODEL_ASSET).use { it.readBytes() } + environment = OrtEnvironment.getEnvironment() + session = environment?.createSession(modelBytes, OrtSession.SessionOptions()) + Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes") + } catch (t: Throwable) { + Log.e(TAG, "DeepCW model failed to load", t) + _errorMessage.value = "CW model failed to load: ${t.message ?: t.javaClass.simpleName}" + } + } + + override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) { + if (session == null || samples.isEmpty()) return + + val resampled = CwDeepSpectrogram.resampleLinear( + samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE + ) + val shouldRedecode = buffer.append(resampled) + if (!shouldRedecode || !buffer.hasEnoughAudio) return + + // Drop this cycle rather than queue when the previous run is still going. + if (!inferenceLock.tryLock()) { + Log.d(TAG, "inference still running, skipping this interval") + return + } + try { + decodeWindow(buffer.snapshot()) + } catch (t: Throwable) { + Log.e(TAG, "inference failed", t) + _errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}" + } finally { + inferenceLock.unlock() + } + } + + private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) { + val activeSession = session ?: return@withContext + val activeEnvironment = environment ?: return@withContext + + val spectrogram = CwDeepSpectrogram.compute(window) + val frames = spectrogram.size + val bins = CwDeepSpectrogram.FREQUENCY_BINS + + val flat = FloatBuffer.allocate(frames * bins) + for (frame in spectrogram) flat.put(frame) + flat.rewind() + + val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong()) + val startedAt = System.currentTimeMillis() + val text: String + OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input -> + activeSession.run(mapOf(inputName to input)).use { result -> + @Suppress("UNCHECKED_CAST") + val logits = result[outputName].get().value as Array> + text = CwCtcDecoder.greedy(logits, chars, blankIndex) + } + } + _lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt() + + // Replace, never append: the model rewrites earlier characters as more + // context arrives, so appending would leave stale guesses on screen. + _decodedText.value = text + + updateSignalMetrics(spectrogram) + } + + /** + * Report the loudest bin as the tone pitch and its prominence over the + * window mean as a 0..1 strength, purely for the UI readout. + */ + private fun updateSignalMetrics(spectrogram: Array) { + if (spectrogram.isEmpty()) return + var bestBin = 0 + var bestValue = 0f + var total = 0f + var count = 0 + for (frame in spectrogram) { + for (bin in frame.indices) { + val value = frame[bin] + total += value + count++ + if (value > bestValue) { + bestValue = value + bestBin = bin + } + } + } + if (count == 0 || bestValue <= 0f) return + + val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH + // Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin. + val absoluteBin = 32 + bestBin + _estimatedPitch.value = (absoluteBin * binHz).toFloat() + + val mean = total / count + _signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f) + } + + override fun reset() { + buffer.reset() + _decodedText.value = "" + _estimatedPitch.value = null + _signalStrength.value = 0f + _lastInferenceMs.value = 0 + } + + override fun close() { + try { + session?.close() + } catch (t: Throwable) { + Log.w(TAG, "session close failed", t) + } + session = null + environment = null + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt new file mode 100644 index 00000000..2aa59d1a --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt @@ -0,0 +1,59 @@ +/* + * 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.StateFlow + +/** + * A CW (Morse code) decoder fed with microphone PCM. + * + * Implementations live outside `core:domain` when they need platform APIs; + * this contract stays pure Kotlin so the UI can depend on it directly. + */ +interface ICwDecoder { + + /** + * Decoded text for display. + * + * Note this is **replace** semantics, not append: a whole-segment model + * revises earlier characters as more audio arrives, so consumers must show + * the current value rather than accumulating emissions. + */ + val decodedText: StateFlow + + /** Detected tone frequency in Hz, or null before a tone is found. */ + val estimatedPitch: StateFlow + + /** Relative signal strength in 0..1 for level meters. */ + val signalStrength: StateFlow + + /** Most recent inference duration in milliseconds, for diagnostics. */ + val lastInferenceMs: StateFlow + + /** Non-null when the decoder cannot run, for example the model failed to load. */ + val errorMessage: StateFlow + + /** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */ + suspend fun processBuffer(samples: FloatArray, sampleRate: Int) + + /** Clear decoded text and buffered audio. */ + fun reset() + + /** Release native resources. Must be called when the decoder goes away. */ + fun close() +}