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 021db98b..95cc1c03 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 @@ -65,13 +65,28 @@ class CwDeepDecoder( private val isToneShiftEnabled: () -> Boolean = { false } ) : ICwDecoder { - private companion object { + // internal, not private: the archive timing is user-visible behaviour and its test asserts + // against these constants directly rather than a copy that could silently drift. + internal companion object { 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 + /** + * Evicted audio is decoded into permanent history once this much accumulates. + * + * This is the delay before decoded text reaches the record, and it is additive with + * the 20 s live window: at the old 15 s the record showed nothing for the first 35 s + * of a session, and thereafter text that had scrolled out of the live window sat + * invisible for up to 15 s before landing - the record appeared to stall and, when + * it was still concatenating the live window, to delete what it had just shown. + * + * Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap + * under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not + * stall for longer than the one thing an operator most needs to read back - while the + * archive path still fires less than half as often as the 1.5 s live redecode cycle. + */ + const val ARCHIVE_SECONDS = 4.0 val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt() /** @@ -105,13 +120,12 @@ class CwDeepDecoder( override val decodedText: StateFlow = _decodedText.asStateFlow() /** - * Archived text, plus a provisional decode of audio not yet archived. + * Archived text: only appended to, so a pane bound to it never loses what it showed. * - * Kept as one flow of the two parts concatenated. Without the provisional part the - * transcript visibly shrank: audio leaving the 20 s window waits for a full - * [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its - * characters were in neither place - measured, up to 30 characters at 20 WPM would - * vanish and reappear later, which reads as the box deleting text. + * This once carried a provisional tail meant to cover the gap while audio waited to be + * archived, but the decode feeding that tail was never wired up, so the tail was always + * empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS] + * batches, which no longer concatenate anything that gets rewritten. */ private val _historyText = MutableStateFlow("") override val historyText: StateFlow = _historyText.asStateFlow() @@ -119,9 +133,6 @@ class CwDeepDecoder( /** Permanently archived text; the provisional tail is appended to this for display. */ private var committedText = "" - /** Provisional decode of the pending archive batch, replaced when it is archived. */ - private var pendingText = "" - private val _estimatedPitch = MutableStateFlow(null) override val estimatedPitch: StateFlow = _estimatedPitch.asStateFlow() @@ -143,11 +154,13 @@ class CwDeepDecoder( 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. + * Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded + * once and appended to [historyText]. The batch stays short enough that the record does + * not visibly stall, and accuracy barely suffers: this content has already been through + * the 20 s window many times, so the archive decode is a confirmation, not a first look. + * + * Sized to the full window rather than the batch: [flush] hands over whatever the live + * window holds, which can be the whole 20 s. */ private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE) private var archiveSize = 0 @@ -410,9 +423,7 @@ class CwDeepDecoder( private fun dropBufferedAudio() { buffer.reset() archiveSize = 0 - // The provisional text describes audio being discarded, so it goes with it. // Committed text stays: it was correct for audio that really was archived. - pendingText = "" _historyText.value = committedText } @@ -497,6 +508,37 @@ class CwDeepDecoder( updateSignalMetrics(spectrogram) } + /** + * Archive whatever audio is still in the pipeline, so stopping does not discard it. + * + * Two places hold audio that would otherwise never be decoded into the record: the + * batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose + * contents only ever reach the archive by being pushed out by newer audio. Together + * that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session + * - which includes the end of every transmission, the part with the call sign in it. + * + * Order matters: the pending batch left the window before anything still in it, so it + * has to be archived first or the record comes out with its text transposed. + */ + override suspend fun flush() { + if (archiveSize > 0) { + val pending = archiveBuffer.copyOf(archiveSize) + archiveSize = 0 + runCatching { archiveDecode(pending) } + .onFailure { if (it is CancellationException) throw it } + } + // Draining the window empties it, so a second flush cannot double-archive the tail. + val window = buffer.snapshot() + if (window.isNotEmpty()) { + buffer.reset() + runCatching { archiveDecode(window) } + .onFailure { if (it is CancellationException) throw it } + } + // The live line described audio that is now in the record; leaving it would show the + // same characters twice, in two places, one of them stale. + _decodedText.value = "" + } + /** * 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 — @@ -508,29 +550,10 @@ class CwDeepDecoder( if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext val spectrogram = CwDeepSpectrogram.compute(audio) val text = runInference(activeSession, activeEnvironment, spectrogram) - // This batch is final, so its provisional decode is superseded rather than kept. committedText += text - pendingText = "" _historyText.value = committedText } - /** - * Decode the audio waiting to be archived, so it stays on screen until it is. - * - * Provisional: the batch is still growing, and the final decode sees all of it at once - * with more context. Runs on the redecode cycle rather than per capture chunk - - * decoding every 100 ms chunk separately measured 14x the inference load, over 250% of - * one core, and a chunk that short carries under two dot-lengths of context anyway. - */ - private suspend fun decodePending(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) - pendingText = runInference(activeSession, activeEnvironment, spectrogram) - _historyText.value = committedText + pendingText - } - /** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */ private fun runInference( activeSession: OrtSession, @@ -611,7 +634,6 @@ class CwDeepDecoder( _decodedText.value = "" _historyText.value = "" committedText = "" - pendingText = "" archiveSize = 0 _estimatedPitch.value = null _detectedToneHz.value = null diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwArchiveTimingTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwArchiveTimingTest.kt new file mode 100644 index 00000000..8e74b9ad --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwArchiveTimingTest.kt @@ -0,0 +1,150 @@ +/* + * 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 com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer +import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.floor + +/** + * How long audio waits before its text can reach the record pane. + * + * The record binds to archived text only. The live decode is rewritten from scratch every + * cycle, so a pane that concatenated it lost characters the operator had already read - the + * decoder appeared to delete its own output while still running. Binding to archived text + * makes the pane monotonic, and the cost is latency, which is additive: audio must first be + * pushed out of the live window, then accumulate into a full archive batch. + * + * [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here. + * These tests read its real constants rather than copies, so retuning one without + * reconsidering the user-visible delay fails here. + */ +class CwArchiveTimingTest { + + /** Sending speed for the character counts, typical for satellite CW. */ + private val wpm = 18.0 + + /** PARIS standard: one word is five characters. */ + private val charsPerSecond = wpm * 5 / 60.0 + + private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS + private val batch = CwDeepDecoder.ARCHIVE_SECONDS + + /** Seconds of audio that have reached the archive after listening for [elapsed]. */ + private fun archivedSeconds(elapsed: Double): Double { + val evicted = elapsed - window + if (evicted <= 0.0) return 0.0 + return floor(evicted / batch) * batch + } + + @Test + fun thresholdMatchesTheDeclaredBatchLength() { + assertEquals( + "threshold must be the batch length in samples", + (CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(), + CwDeepDecoder.ARCHIVE_THRESHOLD + ) + } + + @Test + fun archiveBatchIsShorterThanACallSign() { + // A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that + // means the record can stall for longer than the single most important thing being + // sent, which is what made the stall read as deletion. + val callSignSeconds = 7 / charsPerSecond + assertTrue( + "batch $batch s must not exceed a call sign at $wpm WPM " + + "(${"%.1f".format(callSignSeconds)} s)", + batch <= callSignSeconds + ) + } + + @Test + fun firstTextReachesTheRecordWithinHalfAMinute() { + // At the previous 15 s batch this was 35 s, so a short exchange ended with the record + // still completely empty: every decoded character had only ever been in the live line, + // which shows 64 characters and overwrites them. + val firstArchive = window + batch + assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0) + } + + @Test + fun aThirtySecondSessionStillProducesARecord() { + val archived = archivedSeconds(30.0) + assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0) + } + + @Test + fun theRecordNeverStallsForLongerThanOneBatch() { + var longestStall = 0.0 + var lastGrowthAt = window + var previous = 0.0 + var t = window + while (t <= 600.0) { + val archived = archivedSeconds(t) + if (archived > previous) { + longestStall = maxOf(longestStall, t - lastGrowthAt) + lastGrowthAt = t + previous = archived + } + t += 0.1 + } + assertTrue( + "record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s", + longestStall <= batch + 0.11 + ) + } + + @Test + fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() { + // Why flush() exists. Neither holding place drains on its own: the live window only + // reaches the archive by being pushed out by newer audio, and the pending batch only + // by filling up. Both hold the end of the transmission, where the call sign is. + val worstCase = window + batch + val lostCharacters = worstCase * charsPerSecond + assertTrue( + "flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost", + lostCharacters > 20 + ) + } + + @Test + fun archivingStaysRarerThanTheLiveDecode() { + // Each batch is one inference. Shortening the batch trades CPU for latency, so it must + // stay rarer than the live redecode or the archive path becomes the dominant cost. + val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0 + assertTrue( + "batch $batch s must stay longer than the live cycle $liveIntervalSeconds s", + batch > liveIntervalSeconds + ) + } + + @Test + fun aBatchIsLongEnoughToDecode() { + // compute() rejects audio shorter than one FFT frame, so a batch below that would be + // silently dropped by archiveDecode's size guard and its text lost outright. + assertTrue( + "batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " + + "FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}", + CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH + ) + } +} 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 0746ceb3..c2c95353 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 @@ -37,9 +37,11 @@ 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. + * Transcript of audio that has been archived, and will not be revised. + * + * Only ever grows until [reset]. [decodedText] is rewritten from scratch on every + * redecode, so a pane that concatenates it loses text the operator has already read - + * which a paper log does not do. This is the flow such a pane must bind to. */ val historyText: StateFlow @@ -74,6 +76,17 @@ interface ICwDecoder { /** Non-null when the decoder cannot run, for example the model failed to load. */ val errorMessage: StateFlow + /** + * Decode whatever audio is still held in the pipeline into [historyText]. + * + * Nothing reaches [historyText] until audio has been pushed out of the live window and + * then accumulated into a full archive batch, so the last stretch of a session is always + * still in flight when capture stops - and neither holding place drains on its own. That + * stretch is the end of the transmission, the part with the call sign in it. Call on + * pause and before [close]. + */ + suspend fun flush() + /** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */ suspend fun processBuffer(samples: FloatArray, sampleRate: Int) 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 8f77c306..77395e7f 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 @@ -58,9 +58,11 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -108,6 +110,12 @@ fun CwDecodeScreen() { // Hoisted so the toolbar button can reach it: the record below does not follow // the decode, so jumping to the newest text has to be an explicit action. val transcriptScroll = rememberScrollState() + val clipboard = LocalClipboardManager.current + val showToast = remember { container.provideShowToast() } + val copiedMessage = stringResource(R.string.cw_copied) + val emptyRecordMessage = stringResource(R.string.cw_record_empty) + // Archiving the tail on the way out has to outlive this composable's own scope. + val appScope = container.appScope val decodedText by decoder.decodedText.collectAsState() val historyText by decoder.historyText.collectAsState() @@ -130,7 +138,13 @@ fun CwDecodeScreen() { // permission so granting the permission mid-flow restarts collection // (isListening may already be true when the launcher returns). LaunchedEffect(isListening, permissionGranted) { - if (!isListening) return@LaunchedEffect + if (!isListening) { + // Pausing must not throw away the tail. The live window plus the batch waiting to + // be archived are both still holding audio at this point, and nothing else ever + // decodes either of them, so the end of the transmission would be lost outright. + decoder.flush() + return@LaunchedEffect + } if (!permissionGranted) { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) return@LaunchedEffect @@ -143,8 +157,14 @@ fun CwDecodeScreen() { DisposableEffect(Unit) { onDispose { - // OrtSession holds native memory and must be released explicitly. - decoder.close() + // Archive the tail before tearing down, on a scope that outlives this composable: + // the screen's own scope is cancelled as it leaves, which would abort the decode. + // close() waits for it, because the inference needs the session it is closing. + appScope.launch { + runCatching { decoder.flush() } + // OrtSession holds native memory and must be released explicitly. + decoder.close() + } } } @@ -162,6 +182,24 @@ fun CwDecodeScreen() { .weight(1f) .padding(start = 12.dp) ) + // Copy the record out. The decoder is built fresh every time this screen is opened, + // so leaving the screen loses the transcript - and until now there was no way at all + // to get the text off it. An operator who has just copied a callsign by ear should not + // have to transcribe it a second time by hand. + IconButton( + onClick = { + if (historyText.isNotEmpty()) { + clipboard.setText(AnnotatedString(historyText)) + showToast(copiedMessage) + } + }, + enabled = historyText.isNotEmpty() + ) { + Icon( + painter = painterResource(CoreR.drawable.ic_save), + contentDescription = stringResource(R.string.cw_copy) + ) + } // Jump to the newest text. The record below deliberately does not follow the decode, // so this is how you get back to the bottom after reading earlier traffic. IconButton(onClick = { scope.launch { transcriptScroll.animateScrollTo(transcriptScroll.maxValue) } }) { @@ -239,6 +277,16 @@ fun CwDecodeScreen() { .padding(horizontal = 12.dp, vertical = 6.dp) ) + // The pane below was an unlabelled grey box showing a bare ellipsis, which reads as a + // disabled text field rather than a transcript. Naming it is what makes the split + // between the live line above and the record below legible at all. + Text( + text = stringResource(R.string.cw_record_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, top = 4.dp) + ) + Box( modifier = Modifier .weight(1f) @@ -247,7 +295,13 @@ fun CwDecodeScreen() { .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) ) { - val transcript = (historyText + decodedText).ifEmpty { "…" } + // The record shows settled text only. decodedText is the live 20-second window, + // which the model rewrites from scratch every 1.5 seconds as more context arrives - + // right for the line above, where the newest guess is what you want, but it meant the + // bottom of the record kept changing and sometimes got shorter. That reads as the + // decoder deleting what it just wrote, which for something whose whole purpose is to + // be read back is the one thing it must not do. + val transcript = historyText.ifEmpty { emptyRecordMessage } // This pane does not follow the decode. The single line above it is where new // characters appear; this is the record, and a record that scrolls itself is worse // than paper - you cannot read back over what just arrived because it keeps moving. 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 a0e942ff..8ec5d3f2 100644 --- a/feature/cw/src/main/res/values-zh/app_values.xml +++ b/feature/cw/src/main/res/values-zh/app_values.xml @@ -14,4 +14,8 @@ 音调 %1$d 赫兹,已搬入解码范围 音调 %1$d 赫兹,在解码范围外 音调 %1$d 赫兹 + 复制记录 + 记录已复制 + 记录 + 解码文字会汇集到这里。离开本页前请复制:记录不会被存下来。 diff --git a/feature/cw/src/main/res/values/app_values.xml b/feature/cw/src/main/res/values/app_values.xml index 2a3767da..b959ada6 100644 --- a/feature/cw/src/main/res/values/app_values.xml +++ b/feature/cw/src/main/res/values/app_values.xml @@ -14,4 +14,8 @@ tone at %1$d hertz, moved into the decoder range tone at %1$d hertz, outside the decoder range tone at %1$d hertz + Copy the record + Record copied + Record + Decoded text collects here. Copy it before leaving this screen: the record is not stored.