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.