From cc4f156c83892f741bc3b257f834dcb1ca7796c3 Mon Sep 17 00:00:00 2001 From: QIU Date: Thu, 27 Aug 2026 15:24:01 +0000 Subject: [PATCH] fix(cw): a drifting tone wiped the record instead of filling it The record could go from a screenful of text to less, and then to nothing at all, while decoding was still running. The cause is dropBufferedAudio(), which runs on every change of shift - and the shift tracks the detected tone, which drifts across a pass. The live window is the only route into the archive: audio gets there by being pushed out by newer audio. Clearing the window therefore did not merely discard 20 s, it reset the progress towards ever archiving anything. Modelled at 18 WPM, a drift every 20 s meant five minutes of listening archived not one character, however long the operator waited - the window was always wiped before the first sample could be evicted. With the record still concatenating the live decode at the time, each wipe also cut the visible transcript short, which is the text going backwards and then never accumulating. Retiring the window instead of dropping it fixes both. Those samples cannot stay - one spectrogram over two shift amounts smears the tone - but they were shifted consistently and they are complete, so they decode fine on their own. They are queued and archived by the normal path, keeping dropBufferedAudio() non-suspending: both callers sit on the synchronous capture path, and decoding there would put an inference inside the tone scan. Modelled over 300 s, a drift every 5 s goes from 0 characters archived to 449. An earlier attempt at this - keeping archiveSize instead of zeroing it - was wrong and the probe rejected it: with the window wiped before it ever overflowed, that buffer was empty, so preserving it preserved nothing. The queue is synchronised (written from capture, drained from capture and flush) and capped at four windows, dropping the oldest when full: a tone drifting on every detection scan would otherwise queue faster than the decoder can drain. Also from an audit of the previous two commits: - The copy button was gated on the record, which is empty for the opening half-minute while the first batch accumulates. That greyed out the one control that rescues the text over exactly the short exchange most likely to be lost. It now copies the live window too, and the empty state says which surface updates sooner. - cw_copy, cw_copied, cw_record_label and cw_record_empty were missing from values-id, values-in and values-tr, which carry the full UI strings, so those users saw English. - Dropped a KDoc block left dangling by 8445c170, which had removed the constant it documented. --- .../look4sat/core/data/cw/CwDeepDecoder.kt | 81 ++++++++++++++++++- .../look4sat/feature/cw/CwDecodeScreen.kt | 27 +++---- .../cw/src/main/res/values-id/app_values.xml | 4 + .../cw/src/main/res/values-in/app_values.xml | 4 + .../cw/src/main/res/values-tr/app_values.xml | 4 + .../cw/src/main/res/values-zh/app_values.xml | 2 +- feature/cw/src/main/res/values/app_values.xml | 2 +- 7 files changed, 107 insertions(+), 17 deletions(-) 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 b2596fc5..ba489384 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 @@ -166,6 +166,23 @@ class CwDeepDecoder( private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE) private var archiveSize = 0 + /** + * Window contents retired by a change of shift, waiting to be archived. + * + * The live window is the only route into the archive - audio gets there by being pushed + * out - so clearing the window used to mean its audio was never decoded at all. When the + * shift changed more often than the window took to fill, that was every sample: modelled + * at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever. + * Synchronised on itself: written from the capture path and drained from both there and + * [flush], which run on different coroutines. Capped, because a signal drifting on every + * detection scan would otherwise queue windows faster than they can be decoded and grow + * without bound; past the cap the oldest goes, since newer audio is what is being read. + */ + private val retiredAudio = ArrayDeque() + + /** Windows held awaiting archival before the oldest is dropped. */ + private val retiredAudioLimit = 4 + /** Held while inference runs so slow devices skip work instead of queuing it. */ private val inferenceLock = Mutex() @@ -312,6 +329,21 @@ class CwDeepDecoder( bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE ) val prepared = applyToneShift(resampled) + + // Anything applyToneShift just retired from the window is older than what follows, so + // it is archived before the new audio is buffered - otherwise the record comes out + // with its text transposed. Archived whole rather than accumulated: it is already a + // full window's worth, and holding it back would only expose it to the next retirement. + val retired = drainRetiredAudio() + for (batch in retired) { + try { + archiveDecode(batch) + } catch (t: Throwable) { + if (t is CancellationException) throw t + Log.e(TAG, "retired audio decode failed", t) + } + } + val shouldRedecode = buffer.append(prepared) // Archive audio that scrolled out of the live window. It is decoded once @@ -438,9 +470,49 @@ class CwDeepDecoder( * cannot be un-shifted, so they are dropped rather than decoded against the new * shift. Text already committed to [historyText] stays: it was correct when decoded. */ + /** + * Drop audio that was shifted by a setting no longer in force - but keep what can be kept. + * + * The live window has to go: it holds samples moved by two different amounts, and one + * spectrogram over both smears the tone. The pending archive batch does not. Those samples + * already left the window, they were shifted consistently, and they are complete, so + * discarding them threw away decodable audio for no reason. They are left in place here to + * be archived by the normal path, which keeps this function non-suspending: its two + * callers sit on the synchronous capture path, and making them suspend to run an inference + * here would put a decode inside the tone-detection scan. + * + * It mattered because this runs on every change of shift, which tracks the detected tone, + * which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record + * permanently empty however long the operator listened: the window was wiped before any + * batch could complete, so nothing was ever committed. With the record still concatenating + * the live decode at the time, each wipe visibly cut the transcript short as well - text + * going backwards, then never accumulating at all. + */ + /** Take every retired window, oldest first, leaving the queue empty. */ + private fun drainRetiredAudio(): List = synchronized(retiredAudio) { + if (retiredAudio.isEmpty()) { + emptyList() + } else { + retiredAudio.toList().also { retiredAudio.clear() } + } + } + private fun dropBufferedAudio() { + // Retired, not discarded. The samples cannot stay in the window - mixing two shifts + // in one spectrogram smears the tone - but they are internally consistent and + // complete, so they decode fine on their own. Handed to the archive path rather than + // decoded here, because both callers sit on the synchronous capture path. + val retiring = buffer.snapshot() + if (retiring.isNotEmpty()) { + synchronized(retiredAudio) { + while (retiredAudio.size >= retiredAudioLimit) { + Log.w(TAG, "retired audio queue full, dropping the oldest window") + retiredAudio.removeFirst() + } + retiredAudio.addLast(retiring) + } + } buffer.reset() - archiveSize = 0 // Committed text stays: it was correct for audio that really was archived. _historyText.value = committedText } @@ -539,6 +611,12 @@ class CwDeepDecoder( * has to be archived first or the record comes out with its text transposed. */ override suspend fun flush() = archiveLock.withLock { + // Oldest first, all the way down: retired window contents, then the batch accumulating + // towards the threshold, then what is still live. + for (batch in drainRetiredAudio()) { + runCatching { archiveDecodeLocked(batch) } + .onFailure { if (it is CancellationException) throw it } + } if (archiveSize > 0) { val pending = archiveBuffer.copyOf(archiveSize) archiveSize = 0 @@ -660,6 +738,7 @@ class CwDeepDecoder( override fun reset() { antiAlias?.reset() buffer.reset() + synchronized(retiredAudio) { retiredAudio.clear() } _decodedText.value = "" _historyText.value = "" committedText = "" 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 77395e7f..332d350c 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 @@ -73,13 +73,6 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.R as CoreR import kotlin.math.roundToInt -/** - * Scroll slack, in pixels, within which the transcript counts as being at the bottom. - * - * Not zero: an animated scroll settles a pixel or two short of the maximum, and an exact - * comparison would drop out of follow-mode the moment it did. - */ - /** * Full-page CW decoder backed by DeepCW. * @@ -182,18 +175,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. + // Copy everything decoded so far. The decoder is built fresh every time this screen + // is opened, so leaving loses the transcript, and until now there was no way at all + // to get the text off it: an operator who had just copied a call sign by ear had to + // transcribe it a second time by hand. + // + // Includes the live window, not just the record. The record only holds archived text, + // which needs the window to fill and then a batch to accumulate, so for the opening + // half-minute it is empty - and gating the button on it left the one control that + // rescues the text greyed out over exactly the short exchange most likely to be lost. + val copyable = (historyText + decodedText).trim() IconButton( onClick = { - if (historyText.isNotEmpty()) { - clipboard.setText(AnnotatedString(historyText)) + if (copyable.isNotEmpty()) { + clipboard.setText(AnnotatedString(copyable)) showToast(copiedMessage) } }, - enabled = historyText.isNotEmpty() + enabled = copyable.isNotEmpty() ) { Icon( painter = painterResource(CoreR.drawable.ic_save), 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 d7092aea..09011015 100644 --- a/feature/cw/src/main/res/values-id/app_values.xml +++ b/feature/cw/src/main/res/values-id/app_values.xml @@ -14,4 +14,8 @@ nada %1$d hertz, dipindahkan ke rentang dekode nada %1$d hertz, di luar rentang dekode nada %1$d hertz + Salin catatan + Catatan disalin + Catatan + Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan. 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 d7092aea..09011015 100644 --- a/feature/cw/src/main/res/values-in/app_values.xml +++ b/feature/cw/src/main/res/values-in/app_values.xml @@ -14,4 +14,8 @@ nada %1$d hertz, dipindahkan ke rentang dekode nada %1$d hertz, di luar rentang dekode nada %1$d hertz + Salin catatan + Catatan disalin + Catatan + Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan. 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 6e66824a..47d39729 100644 --- a/feature/cw/src/main/res/values-tr/app_values.xml +++ b/feature/cw/src/main/res/values-tr/app_values.xml @@ -14,4 +14,8 @@ ton %1$d hertz, kod çözme aralığına taşındı ton %1$d hertz, kod çözme aralığının dışında ton %1$d hertz + Kaydı kopyala + Kayıt kopyalandı + Kayıt + Bir ses grubu çözüldükten sonra metin buraya düşer. Üstteki satır daha erken güncellenir. Çıkmadan önce kopyalayın: kayıt saklanmaz. 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 8ec5d3f2..c539e406 100644 --- a/feature/cw/src/main/res/values-zh/app_values.xml +++ b/feature/cw/src/main/res/values-zh/app_values.xml @@ -17,5 +17,5 @@ 复制记录 记录已复制 记录 - 解码文字会汇集到这里。离开本页前请复制:记录不会被存下来。 + 一批音频解码完成后文字会落到这里,上面那行更新更早。离开本页前请复制:记录不会被存下来。 diff --git a/feature/cw/src/main/res/values/app_values.xml b/feature/cw/src/main/res/values/app_values.xml index b959ada6..015f3790 100644 --- a/feature/cw/src/main/res/values/app_values.xml +++ b/feature/cw/src/main/res/values/app_values.xml @@ -17,5 +17,5 @@ Copy the record Record copied Record - Decoded text collects here. Copy it before leaving this screen: the record is not stored. + Text lands here once a batch of audio has been decoded. The line above updates sooner. Copy before leaving: the record is not stored.