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
+291
-44
No files matched your search
@@ -65,13 +65,28 @@ class CwDeepDecoder(
|
|||||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||||
) : ICwDecoder {
|
) : 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 TAG = "CwDeepDecoder"
|
||||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
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()
|
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,13 +120,12 @@ class CwDeepDecoder(
|
|||||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
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
|
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||||
* transcript visibly shrank: audio leaving the 20 s window waits for a full
|
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||||
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
|
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||||
* characters were in neither place - measured, up to 30 characters at 20 WPM would
|
* batches, which no longer concatenate anything that gets rewritten.
|
||||||
* vanish and reappear later, which reads as the box deleting text.
|
|
||||||
*/
|
*/
|
||||||
private val _historyText = MutableStateFlow("")
|
private val _historyText = MutableStateFlow("")
|
||||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
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. */
|
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||||
private var committedText = ""
|
private var committedText = ""
|
||||||
|
|
||||||
/** Provisional decode of the pending archive batch, replaced when it is archived. */
|
|
||||||
private var pendingText = ""
|
|
||||||
|
|
||||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||||
|
|
||||||
@@ -143,11 +154,13 @@ class CwDeepDecoder(
|
|||||||
private val buffer = CwDeepBuffer()
|
private val buffer = CwDeepBuffer()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||||
* keeps the extra inference cheap (short window) while long enough to be
|
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||||
* decoded accurately — the content has already been through the 20 s window
|
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
*
|
||||||
|
* 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 val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
private var archiveSize = 0
|
private var archiveSize = 0
|
||||||
@@ -410,9 +423,7 @@ class CwDeepDecoder(
|
|||||||
private fun dropBufferedAudio() {
|
private fun dropBufferedAudio() {
|
||||||
buffer.reset()
|
buffer.reset()
|
||||||
archiveSize = 0
|
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.
|
// Committed text stays: it was correct for audio that really was archived.
|
||||||
pendingText = ""
|
|
||||||
_historyText.value = committedText
|
_historyText.value = committedText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +508,37 @@ class CwDeepDecoder(
|
|||||||
updateSignalMetrics(spectrogram)
|
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
|
* 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 —
|
* 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
|
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||||
// This batch is final, so its provisional decode is superseded rather than kept.
|
|
||||||
committedText += text
|
committedText += text
|
||||||
pendingText = ""
|
|
||||||
_historyText.value = committedText
|
_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. */
|
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||||
private fun runInference(
|
private fun runInference(
|
||||||
activeSession: OrtSession,
|
activeSession: OrtSession,
|
||||||
@@ -611,7 +634,6 @@ class CwDeepDecoder(
|
|||||||
_decodedText.value = ""
|
_decodedText.value = ""
|
||||||
_historyText.value = ""
|
_historyText.value = ""
|
||||||
committedText = ""
|
committedText = ""
|
||||||
pendingText = ""
|
|
||||||
archiveSize = 0
|
archiveSize = 0
|
||||||
_estimatedPitch.value = null
|
_estimatedPitch.value = null
|
||||||
_detectedToneHz.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>
|
val decodedText: StateFlow<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanent transcript of everything that has scrolled out of the live
|
* Transcript of audio that has been archived, and will not be revised.
|
||||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
*
|
||||||
* what the user reads back after a signal has passed.
|
* 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>
|
val historyText: StateFlow<String>
|
||||||
|
|
||||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
|||||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||||
val errorMessage: StateFlow<String?>
|
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. */
|
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
// 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.
|
// the decode, so jumping to the newest text has to be an explicit action.
|
||||||
val transcriptScroll = rememberScrollState()
|
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 decodedText by decoder.decodedText.collectAsState()
|
||||||
val historyText by decoder.historyText.collectAsState()
|
val historyText by decoder.historyText.collectAsState()
|
||||||
@@ -130,7 +138,13 @@ fun CwDecodeScreen() {
|
|||||||
// permission so granting the permission mid-flow restarts collection
|
// permission so granting the permission mid-flow restarts collection
|
||||||
// (isListening may already be true when the launcher returns).
|
// (isListening may already be true when the launcher returns).
|
||||||
LaunchedEffect(isListening, permissionGranted) {
|
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) {
|
if (!permissionGranted) {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -143,10 +157,16 @@ fun CwDecodeScreen() {
|
|||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
|
// 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.
|
// OrtSession holds native memory and must be released explicitly.
|
||||||
decoder.close()
|
decoder.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -162,6 +182,24 @@ fun CwDecodeScreen() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(start = 12.dp)
|
.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,
|
// 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.
|
// so this is how you get back to the bottom after reading earlier traffic.
|
||||||
IconButton(onClick = { scope.launch { transcriptScroll.animateScrollTo(transcriptScroll.maxValue) } }) {
|
IconButton(onClick = { scope.launch { transcriptScroll.animateScrollTo(transcriptScroll.maxValue) } }) {
|
||||||
@@ -239,6 +277,16 @@ fun CwDecodeScreen() {
|
|||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -247,7 +295,13 @@ fun CwDecodeScreen() {
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
.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
|
// 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
|
// 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.
|
// than paper - you cannot read back over what just arrived because it keeps moving.
|
||||||
|
|||||||
@@ -14,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
|
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
|
||||||
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
|
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
|
||||||
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
|
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
|
||||||
|
<string name="cw_copy">复制记录</string>
|
||||||
|
<string name="cw_copied">记录已复制</string>
|
||||||
|
<string name="cw_record_label">记录</string>
|
||||||
|
<string name="cw_record_empty">解码文字会汇集到这里。离开本页前请复制:记录不会被存下来。</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -14,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
|
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
|
||||||
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
|
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
|
||||||
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
|
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
|
||||||
|
<string name="cw_copy">Copy the record</string>
|
||||||
|
<string name="cw_copied">Record copied</string>
|
||||||
|
<string name="cw_record_label">Record</string>
|
||||||
|
<string name="cw_record_empty">Decoded text collects here. Copy it before leaving this screen: the record is not stored.</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user