fix(cw): the record deleted its own text while decoding
The record pane concatenated the live decode onto the archived text. The live decode
is the 20 s window, replaced wholesale every 1.5 s because DeepCW is a whole-segment
CTC model that rewrites earlier characters as more context arrives. So the tail of
the record kept changing and could get shorter - text vanishing from under the
operator while the decoder was still running.
A previous attempt (828fd0fb) added decodePending() to cover the gap while audio
waits to be archived, but the call site was never wired up. The function had no
callers and pendingText was only ever cleared, never assigned, so that fix has never
once run and the gap it targeted stayed open. Both are deleted here.
The record now binds to archived text only, which is append-only, so it cannot
shrink. That moves the whole problem to latency, which was 20 s window + 15 s batch:
nothing at all in the record for the first 35 s of a session, and thereafter a stall
of up to 15 s each cycle. The batch is now 4 s, holding the stall under the ~4.7 s a
seven-character call sign takes at 18 WPM, while the archive path still fires less
than half as often as the live redecode.
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. So pausing
or leaving the screen discarded whatever was in flight - the end of every
transmission, the part with the call sign in it. flush() archives both, pending batch
first so the text is not transposed, and is called on pause and before close(). On
the way out it runs on appScope, because the screen's own scope is cancelled as it
leaves and would abort the decode.
Also: the record was an unlabelled grey box showing a bare ellipsis, which reads as a
disabled text field. It now has a label, an empty state that says what it is for, and
a copy button - until now there was no way to get the decoded text off the screen at
all, so an operator who had just copied a call sign by ear had to transcribe it a
second time by hand.
CwArchiveTimingTest covers the timing against the real constants rather than copies;
it caught a 5 s batch exceeding the call-sign bound during this change. The archive
path had no test coverage before.
This commit is contained in:
1 parent
78a6f270bf
commit
5ed76dba31
6 files changed
+293
-46
No files matched your search
@@ -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<String> = _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<String> = _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<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,11 @@ interface ICwDecoder {
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* 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<String>
|
||||
|
||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user