diff --git a/README.md b/README.md index 78fbd1b8..b7969497 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,21 @@ It is now and always will be completely ad-free and open-source. * Custom TLE satellite data import is available via Three Line Element .txt files * Offline first: calculations are made offline. Weekly TLE data update is recommended. +## License + +Look4Sat is free software licensed under the [GNU General Public License v3.0](LICENSE). + +The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine) +neural decoding model, which is licensed under the +[GNU Affero General Public License v3.0](feature/cw/licenses/DeepCW-AGPL-3.0.txt) +(AGPL-3.0-only). Provenance, attribution and the applied int8 quantization are +documented in [`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md). + +Because the combined work incorporates an AGPL-3.0 component, the requirements of +AGPL-3.0 Section 13 apply to the combined work as a whole when it is distributed +(GPL-3.0 Section 13 permits this combination). The CW model runs locally on-device +and does not provide services over a network. + ## Star History 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 index 39d472e5..5b7ed644 100644 --- 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 @@ -55,11 +55,18 @@ class CwDeepDecoder(context: Context) : ICwDecoder { const val TAG = "CwDeepDecoder" const val MODEL_ASSET = "deepcw/model.onnx" const val METADATA_ASSET = "deepcw/model.onnx.json" + + /** Evicted audio is decoded into permanent history once this much accumulates. */ + const val ARCHIVE_SECONDS = 15.0 + val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt() } private val _decodedText = MutableStateFlow("") override val decodedText: StateFlow = _decodedText.asStateFlow() + private val _historyText = MutableStateFlow("") + override val historyText: StateFlow = _historyText.asStateFlow() + private val _estimatedPitch = MutableStateFlow(null) override val estimatedPitch: StateFlow = _estimatedPitch.asStateFlow() @@ -74,6 +81,16 @@ class CwDeepDecoder(context: Context) : ICwDecoder { private val buffer = CwDeepBuffer() + /** + * Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then + * is decoded once and appended to [historyText]. Archiving in ~15 s chunks + * keeps the extra inference cheap (short window) while long enough to be + * decoded accurately — the content has already been through the 20 s window + * many times, so a slightly shorter archive decode loses almost nothing. + */ + private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE) + private var archiveSize = 0 + /** Held while inference runs so slow devices skip work instead of queuing it. */ private val inferenceLock = Mutex() @@ -157,6 +174,21 @@ class CwDeepDecoder(context: Context) : ICwDecoder { samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE ) val shouldRedecode = buffer.append(resampled) + + // Archive audio that scrolled out of the live window. It is decoded once + // when a full archive chunk has accumulated, so old text does not vanish. + val overflow = buffer.drainOverflow() + if (overflow.isNotEmpty()) { + for (v in overflow) { + if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v + } + if (archiveSize >= ARCHIVE_THRESHOLD) { + val audio = archiveBuffer.copyOf(archiveSize) + archiveSize = 0 + archiveDecode(audio) + } + } + if (!shouldRedecode || !buffer.hasEnoughAudio) return // Drop this cycle rather than queue when the previous run is still going. @@ -186,6 +218,37 @@ class CwDeepDecoder(context: Context) : ICwDecoder { CwProbe.step("infer_begin frames=${window.size}") val spectrogram = CwDeepSpectrogram.compute(window) + val text = runInference(activeSession, activeEnvironment, spectrogram) + + // 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) + } + + /** + * Decode a chunk of audio that has scrolled out of the live window and + * append it to [historyText]. Unlike the live window this never replaces — + * the archived audio is final, so its text is permanent. + */ + private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) { + val activeSession = session ?: return@withContext + val activeEnvironment = environment ?: return@withContext + if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext + val spectrogram = CwDeepSpectrogram.compute(audio) + val text = runInference(activeSession, activeEnvironment, spectrogram) + if (text.isNotEmpty()) { + _historyText.value += text + } + } + + /** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */ + private fun runInference( + activeSession: OrtSession, + activeEnvironment: OrtEnvironment, + spectrogram: Array + ): String { val frames = spectrogram.size val bins = CwDeepSpectrogram.FREQUENCY_BINS @@ -205,12 +268,7 @@ class CwDeepDecoder(context: Context) : ICwDecoder { } _lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt() CwProbe.step("infer_done ms=${_lastInferenceMs.value}") - - // 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) + return text } /** @@ -248,6 +306,8 @@ class CwDeepDecoder(context: Context) : ICwDecoder { override fun reset() { buffer.reset() _decodedText.value = "" + _historyText.value = "" + archiveSize = 0 _estimatedPitch.value = null _signalStrength.value = 0f _lastInferenceMs.value = 0 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt index 5b4a4344..be43106e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt @@ -54,9 +54,22 @@ class CwDeepBuffer( private var filled = 0 private var sinceLastRedecode = 0 + /** + * Samples evicted from the ring once it is full. They are the audio that + * has scrolled out of the 20 s window, and are handed off (via + * [drainOverflow]) so the decoder can archive them into permanent history + * instead of silently dropping the corresponding text. Pre-allocated to + * [capacity]: overflow never exceeds one window before it is drained. + */ + private val overflow = FloatArray(capacity) + private var overflowSize = 0 + /** Samples currently buffered, never above [capacity]. */ val size: Int get() = filled + /** Samples currently held in the overflow (awaiting archival). */ + val overflowCount: Int get() = overflowSize + /** True once there is enough audio for the spectrogram to yield a frame. */ val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH @@ -71,10 +84,15 @@ class CwDeepBuffer( // A chunk longer than the window can only contribute its tail. val start = maxOf(0, chunk.size - capacity) for (i in start until chunk.size) { + if (filled == capacity) { + // The slot we are about to overwrite holds the oldest + // sample — move it to the overflow for archival. + overflow[overflowSize++] = ring[writeIndex] + } ring[writeIndex] = chunk[i] writeIndex = (writeIndex + 1) % capacity + if (filled < capacity) filled++ } - filled = minOf(capacity, filled + (chunk.size - start)) } sinceLastRedecode += chunk.size @@ -98,11 +116,25 @@ class CwDeepBuffer( return out } - /** Drop all audio and restart the re-decode interval. */ + /** + * Return the evicted samples (chronological order) and clear the overflow. + * Safe to call every append; returns an empty array when nothing has been + * evicted yet. + */ + fun drainOverflow(): FloatArray { + if (overflowSize == 0) return FloatArray(0) + val out = overflow.copyOf(overflowSize) + overflowSize = 0 + return out + } + + /** Drop all audio (including pending overflow) and restart the interval. */ fun reset() { writeIndex = 0 filled = 0 sinceLastRedecode = 0 + overflowSize = 0 ring.fill(0f) + overflow.fill(0f) } } 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 index 2aa59d1a..2672c651 100644 --- 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 @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.StateFlow interface ICwDecoder { /** - * Decoded text for display. + * Decoded text for the *current* window. * * Note this is **replace** semantics, not append: a whole-segment model * revises earlier characters as more audio arrives, so consumers must show @@ -36,6 +36,13 @@ interface ICwDecoder { */ val decodedText: StateFlow + /** + * Permanent transcript of everything that has scrolled out of the live + * window. Unlike [decodedText] this only ever grows (until [reset]); it is + * what the user reads back after a signal has passed. + */ + val historyText: StateFlow + /** Detected tone frequency in Hz, or null before a tone is found. */ val estimatedPitch: StateFlow diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt index 7cb76591..2f6bf496 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt @@ -118,4 +118,35 @@ class CwDeepBufferTest { val buffer = CwDeepBuffer() assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity) } + + @Test + fun overflowCollectsEvictedSamplesInOrder() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4 + buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) + assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount) + buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2 + assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f) + assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f) + } + + @Test + fun drainOverflowClearsItself() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) + buffer.append(floatArrayOf(5f)) + assertEquals(1, buffer.overflowCount) + buffer.drainOverflow() + assertEquals(0, buffer.overflowCount) + assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f) + } + + @Test + fun resetClearsOverflow() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) + buffer.append(floatArrayOf(5f)) + assertEquals(1, buffer.overflowCount) + buffer.reset() + assertEquals(0, buffer.overflowCount) + } } 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 c5264d88..636f109f 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 @@ -94,6 +94,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) { var isListening by remember { mutableStateOf(false) } val decodedText by decoder.decodedText.collectAsState() + val historyText by decoder.historyText.collectAsState() val estimatedPitch by decoder.estimatedPitch.collectAsState() val signalStrength by decoder.signalStrength.collectAsState() val inferenceMs by decoder.lastInferenceMs.collectAsState() @@ -207,7 +208,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) { .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) ) { Text( - text = decodedText, + text = (historyText + decodedText).ifEmpty { "…" }, modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) 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 index 7fcc60d1..5c5a4109 100644 --- 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 @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram import kotlinx.coroutines.flow.MutableStateFlow @@ -96,7 +97,7 @@ class CwWaterfallState(private val historyRows: Int = 96) { /** * Draws the waterfall newest-row-last, one pixel column per frequency bin. - * Colour ramp goes dark blue -> cyan -> yellow with magnitude. + * Colour ramp is the inferno palette (black -> purple -> orange -> yellow). */ @Composable internal fun CwWaterfallView( @@ -126,11 +127,14 @@ internal fun CwWaterfallView( 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 + // Linear interpolation between adjacent bins via a horizontal + // gradient removes the blocky "pixel" look of 65 discrete columns. + for (bin in 0 until row.size - 1) { + val m0 = (row[bin] / peak).coerceIn(0f, 1f) + val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f) + if (m0 < 0.06f && m1 < 0.06f) continue drawRect( - color = rampColor(magnitude), + brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))), topLeft = Offset(bin * binWidth, y), size = Size(binWidth + 1f, rowHeight + 1f) ) @@ -147,13 +151,32 @@ internal fun CwWaterfallView( } } -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) +/** + * matplotlib "inferno" colour map, approximated with piecewise-linear stops + * (black -> purple -> magenta-red -> orange -> pale yellow). The same palette + * used for the static spectrogram illustration, kept for visual consistency. + */ +private val INFERNO_STOPS = arrayOf( + floatArrayOf(0.00f, 0.000f, 0.000f, 0.016f), // black + floatArrayOf(0.25f, 0.231f, 0.059f, 0.439f), // deep purple + floatArrayOf(0.50f, 0.549f, 0.161f, 0.506f), // magenta + floatArrayOf(0.75f, 0.871f, 0.286f, 0.408f), // red-orange + floatArrayOf(1.00f, 0.988f, 1.000f, 0.643f) // pale yellow +) + +private fun inferno(t: Float): Color { + val x = t.coerceIn(0f, 1f) + for (i in 0 until INFERNO_STOPS.size - 1) { + val a = INFERNO_STOPS[i] + val b = INFERNO_STOPS[i + 1] + if (x <= b[0]) { + val f = (x - a[0]) / (b[0] - a[0]) + return Color( + red = a[1] + (b[1] - a[1]) * f, + green = a[2] + (b[2] - a[2]) * f, + blue = a[3] + (b[3] - a[3]) * f + ) + } } + return Color(0.988f, 1.0f, 0.643f) }