Compare commits

..
6 Commits
Author SHA1 Message Date
mckero abb73ffef7 build: bump to 4.6.2 (versionCode 469)
Carries the CW record fixes: the record no longer deletes its own text while decoding,
a drifting tone no longer wipes it instead of filling it, the archive path no longer
races itself when capture stops, and the decoded text can finally be copied off the
screen.

Also stops the APRS version string drifting, which the comment on it had predicted and
which had already happened again: it still read 4.6.0 while the app shipped 4.6.1, so
every station on the network was told the wrong version. AprsReporter now takes the
version as a parameter and the app module passes BuildConfig.VERSION_NAME, which
required turning on the buildConfig feature - AGP 8 does not generate the class
otherwise. The literal cannot fall out of step with the build again.
2026-08-27 15:41:07 +00:00
mckero cc4f156c83 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.
2026-08-27 15:24:01 +00:00
mckero 3810e76ea2 fix(cw): the archive path raced itself when capture stopped
flush() runs on a different coroutine from processBuffer - on pause from the screen's
own effect, and from the app scope as the screen leaves - so both were appending to
committedText concurrently. That append is a read, an inference lasting hundreds of
milliseconds, and only then a write, so the window for interleaving is the whole
inference: the later write wins and an entire batch of text is gone. Modelled over 40
trials the unlocked version lost 160 characters, averaging a full batch each time.

Worse, both paths touch archiveBuffer and archiveSize. flush() zeroes the index after
copying out a snapshot; the capture coroutine then writes from zero into slots that
snapshot already covered, so the same audio decodes twice and the text appears twice.

Both failures land at the moment the operator stops listening and starts reading,
which is the worst possible time for the record to be wrong.

archiveLock now serialises the archive path. It is a separate mutex from
inferenceLock, which is a tryLock that drops work when contended - right for the live
window, where another decode is 1.5 s away, and wrong here, where dropping a batch
discards the audio for good. Mutex is not reentrant, so archiveDecode is split into a
locking shell and archiveDecodeLocked for callers already holding it.

reset() is left unsynchronised and now says so: an archive decode in flight can land
after it returns, leaving a few characters behind. Making it suspend to close that
window would push suspension onto every caller including a button handler, and the
operator who asked to clear can ask again.
2026-08-27 15:09:24 +00:00
mckero 5ed76dba31 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.
2026-08-27 15:01:04 +00:00
mckero 78a6f270bf fix(cw): filter before decimating, so high tones stop smearing across the band
The operator reported that any tone leaked across the whole display - "even 3 kHz spreads
over the entire band, like taking a piss". The tone shifter was the suspect, since it had
been changed recently. It turned out to be innocent: the audio reaching it was already
ruined.

Capture runs at 44100 Hz and the model needs 3200 Hz, so resampleLinear decimates by a
factor of nearly 14. It interpolates between samples and nothing removes the content above
the new Nyquist of 1600 Hz first, which is the one thing decimation cannot skip. Measured on
44100 Hz input:

    3000 Hz tone  ->  ghost at  200 Hz, 119x the spectral mean
    2400 Hz tone  ->  ghost at  800 Hz
    1800 Hz tone  ->  ghost at 1400 Hz
    5000 Hz tone  ->  ghost at 1400 Hz

Each ghost is as strong as a real signal, so a tone nothing is transmitting on looks
entirely convincing. Worse for actually copying anything: the whole 1600-22050 Hz band of
hiss folds down on top of the signal and lifts the noise floor across the display. That is
the smearing.

CwAntiAlias is a 127-tap windowed-sinc low-pass, Blackman-windowed because sidelobe level is
what decides how much of the folded band survives, cut off at 92% of the target Nyquist so
the transition lands inside the discarded region. Measured suppression at the fold
frequency: 2400 Hz down 69 dB, 3000 Hz down 81 dB, 5000 Hz down 96 dB. 1800 Hz only makes
16 dB - it sits just past the 1472 Hz cut-off and 127 taps cannot be steeper without costing
more time than a phone has during a pass. The tests assert the measured numbers rather than
the ones I hoped for.

resampleLinear itself is untouched. Its comment notes it matches the reference implementation
DeepCW was trained against, so changing its arithmetic would move the spectrogram away from
what the model expects.

The streaming path holds output back by the group delay. A first attempt let the lookahead
taps read zeros at the end of each chunk, which diverged from whole-buffer filtering by
0.134 across the last 44 samples of every chunk - a click at each boundary. Holding output
back makes the two identical to within 1e-8. The cost is 63 samples, 1.4 ms, against a 20 WPM
dot of about 60 ms.

Both the decoder and the waterfall filter now. The waterfall mattered as much as the
decoder: it was showing the folded spectrum, which is what the operator was looking at.
2026-08-27 14:03:30 +00:00
mckero 8445c17033 fix: remove the receive-only notice, and stop the CW record scrolling itself
Two things the operator asked for after running 4.6.1.

The receive-only notice named a state this app does not have. APRS-IS lets an unverified
station connect and then discards its packets, which is what "receive-only" means at the
protocol level - but this app only reports its own position. There is no receiving side to
it, and none intended, so telling the operator they are in receive-only mode described a
mode that does not exist here. Without a passcode the packet does not arrive, and the
unverified notice already says exactly that. The string is gone from all five locales,
along with the AprsReport.receiveOnly field, which had no remaining consumer.

AprsPasscode.classify stays: loginValue still uses it, and its tests hold the distinction
between a deliberate -1 and a typo, which is a separate defect worth keeping fixed.

The CW history pane no longer follows the decode. Its whole purpose is to be read back, and
a record that scrolls itself is worse than paper - as the operator put it, if it scrolls
away then why use a decoder instead of listening and writing it down, since paper does not
erase itself. The single line above it is where new characters appear; that still scrolls,
because that is its job. A down arrow in the toolbar jumps to the newest text when wanted.

Not fixed here: logged times in the log page look wrong and inconsistent. I proposed a
timezone explanation and wrote a probe, and the probe disproved it - on a real JVM both the
session header and the row times are stable and both resolve to local time. That reverted
attempt is not in this commit. The cause is still unknown.
2026-08-27 07:31:20 +00:00
21 changed files with 913 additions and 106 deletions

No files matched your search

@@ -15,6 +15,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.IBinder import android.os.IBinder
import com.rtbishop.look4sat.BuildConfig
import com.rtbishop.look4sat.MainApplication import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig import com.rtbishop.look4sat.core.data.aprs.AprsConfig
@@ -109,6 +110,8 @@ class AprsForegroundService : Service() {
startForegroundWithNotification(cfg) startForegroundWithNotification(cfg)
val rep = AprsReporter( val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) }, configProvider = { AprsStore.loadConfig(this) },
// The real version, so the login line cannot drift from the build again.
appVersion = BuildConfig.VERSION_NAME,
positionProvider = { stationPosition() }, positionProvider = { stationPosition() },
onState = { lastState = it }, onState = { lastState = it },
onReport = { report -> onReport = { report ->
@@ -123,13 +126,13 @@ class AprsForegroundService : Service() {
// An unverified login needs its own message: the write succeeded, so a bare // An unverified login needs its own message: the write succeeded, so a bare
// failure notice would send the operator looking at their network when the // failure notice would send the operator looking at their network when the
// problem is the passcode - and APRS-IS is dropping every packet meanwhile. // problem is the passcode - and APRS-IS is dropping every packet meanwhile.
// No receive-only notice. APRS-IS lets an unverified station connect and then
// discards its packets, but this app only reports its own position - there is no
// receiving side to it - so telling the operator they are "in receive-only mode"
// named a state that does not exist here. Without a passcode the packet does not
// arrive, and that is what the failure notice says.
val msg = when { val msg = when {
report.ok -> getString(R.string.aprs_toast_ok) report.ok -> getString(R.string.aprs_toast_ok)
// Receive-only first: it logs in with -1 exactly as a wrong passcode does and
// the server answers "unverified" to both, so without this branch the one safe
// way to test a setup reported itself as a configuration error.
!report.verified && report.receiveOnly ->
getString(R.string.aprs_toast_receive_only)
!report.verified -> getString(R.string.aprs_toast_unverified) !report.verified -> getString(R.string.aprs_toast_unverified)
else -> getString(R.string.aprs_toast_fail, report.detail) else -> getString(R.string.aprs_toast_fail, report.detail)
} }
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
versionCode = libs.versions.appVersionCode.get().toInt() versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get() versionName = libs.versions.appVersionName.get()
} }
// The APRS login line reports the app version to every station on the network. It used
// to be a literal in core:data and drifted twice, so the app module now reads
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
buildFeatures {
buildConfig = true
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
@@ -52,12 +52,20 @@ data class AprsReport(
* test a setup without putting anything on the network - was reported as a wrong passcode * test a setup without putting anything on the network - was reported as a wrong passcode
* and sent the operator to fix something they had set on purpose. * and sent the operator to fix something they had set on purpose.
*/ */
val receiveOnly: Boolean = false
) )
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */ /** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter( class AprsReporter(
private val configProvider: () -> AprsConfig, private val configProvider: () -> AprsConfig,
/**
* The app's own version, reported to APRS-IS in the login line.
*
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
* network was told the wrong version. A caller in the app module can read the real one.
*/
private val appVersion: String,
private val positionProvider: () -> Pair<Double, Double>? = { null }, private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {}, private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {} private val onReport: (AprsReport) -> Unit = {}
@@ -127,13 +135,6 @@ class AprsReporter(
} }
val packetLine = (beacon as AprsBeacon.Result.Line).text val packetLine = (beacon as AprsBeacon.Result.Line).text
// Receive-only is a deliberate choice and has to be told apart from a typo, because
// both log in with -1 and the server answers "unverified" to each. Classifying rather
// than asking canTransmit: that collapses ReceiveOnly and Mismatch into one boolean,
// so a mistyped passcode would be told it is in receive-only mode - the same
// mis-diagnosis as before, pointing the other way.
val wantsReceiveOnly =
AprsPasscode.classify(cfg.callsign, cfg.passcode) is AprsPasscode.Entry.ReceiveOnly
val c = client ?: AprsIsClient( val c = client ?: AprsIsClient(
host = cfg.server, host = cfg.server,
port = cfg.port, port = cfg.port,
@@ -143,11 +144,8 @@ class AprsReporter(
// transmitting under a passcode the app invented for an unchecked licence. // transmitting under a passcode the app invented for an unchecked licence.
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode), passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens. // Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
// The version is hardcoded and drifts - it read 4.5.4 while the app was 4.6.0.
// core:data has no BuildConfig, so fixing that properly means passing it in from
// the app module; noting rather than doing it here to keep this change small.
softwareName = "Look4Sat", softwareName = "Look4Sat",
version = "4.6.0" version = appVersion
).also { client = it } ).also { client = it }
if (!c.isConnected) c.connect() if (!c.isConnected) c.connect()
onState(AprsState.Connected) onState(AprsState.Connected)
@@ -166,14 +164,13 @@ class AprsReporter(
// report, say that instead. // report, say that instead.
val reported = when { val reported = when {
ok -> detail ok -> detail
refused && wantsReceiveOnly -> "receive-only, not forwarded"
refused -> "login not verified" refused -> "login not verified"
else -> detail else -> detail
} }
onReport( onReport(
AprsReport( AprsReport(
System.currentTimeMillis(), packetLine, ok, reported, System.currentTimeMillis(), packetLine, ok, reported,
verified = !refused, receiveOnly = wantsReceiveOnly verified = !refused
) )
) )
if (ok) onState(AprsState.Connected) else onState(AprsState.Error) if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
@@ -24,6 +24,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.nio.FloatBuffer import java.nio.FloatBuffer
@@ -64,13 +66,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()
/** /**
@@ -104,13 +121,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()
@@ -118,9 +134,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()
@@ -142,18 +155,54 @@ 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
/**
* 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<FloatArray>()
/** 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. */ /** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex() private val inferenceLock = Mutex()
/**
* Serialises the archive path, which mutates state the capture coroutine also touches.
*
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
* scope as the screen leaves - and both append to [committedText], which is a read, an
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
* later write wins and a whole batch of text is lost, precisely at the moment the
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
* makes the same audio decode twice.
*
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
* archive batch discards the audio for good).
*/
private val archiveLock = Mutex()
/** Decides what shift to apply from successive tone estimates. */ /** Decides what shift to apply from successive tone estimates. */
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ) private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
@@ -172,6 +221,17 @@ class CwDeepDecoder(
/** Carries Hilbert filter history and mixer phase across capture chunks. */ /** Carries Hilbert filter history and mixer phase across capture chunks. */
private val streamingShifter = CwToneShifter.Streaming() private val streamingShifter = CwToneShifter.Streaming()
/**
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
*
* Built on the first chunk because the capture rate is not known until then. Without
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
* folded down on top of the signal.
*/
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
/** /**
* Previous value of the setting, so a toggle can invalidate buffered audio. * Previous value of the setting, so a toggle can invalidate buffered audio.
* Null until the first chunk: a decoder created while the setting is already on * Null until the first chunk: a decoder created while the setting is already on
@@ -255,10 +315,35 @@ class CwDeepDecoder(
if (samples.isEmpty()) return if (samples.isEmpty()) return
if (!ensureLoaded()) return if (!ensureLoaded()) return
// Filter before decimating. resampleLinear interpolates without removing anything
// above the new Nyquist, so this has to happen first or the fold is already baked in.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(samples) ?: samples
// The filter holds back its group delay, so the first call returns nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear( val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
) )
val prepared = applyToneShift(resampled) 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) val shouldRedecode = buffer.append(prepared)
// Archive audio that scrolled out of the live window. It is decoded once // Archive audio that scrolled out of the live window. It is decoded once
@@ -385,12 +470,50 @@ class CwDeepDecoder(
* cannot be un-shifted, so they are dropped rather than decoded against the new * 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. * 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<FloatArray> = synchronized(retiredAudio) {
if (retiredAudio.isEmpty()) {
emptyList()
} else {
retiredAudio.toList().also { retiredAudio.clear() }
}
}
private fun dropBufferedAudio() { 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() 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. // Committed text stays: it was correct for audio that really was archived.
pendingText = ""
_historyText.value = committedText _historyText.value = committedText
} }
@@ -475,40 +598,63 @@ 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() = 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
runCatching { archiveDecodeLocked(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 { archiveDecodeLocked(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 —
* the archived audio is final, so its text is permanent. * the archived audio is final, so its text is permanent.
*/ */
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) { private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
archiveDecodeLocked(audio)
}
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext val activeEnvironment = environment ?: return@withContext
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,
@@ -583,12 +729,19 @@ class CwDeepDecoder(
_signalStrength.value = if (decodable) prominence else 0f _signalStrength.value = if (decodable) prominence else 0f
} }
/**
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
* flight can land its batch after this returns, leaving a few characters behind. The
* operator asked to clear and can ask again; making this suspend to close that window
* would push it onto every caller, including a synchronous button handler.
*/
override fun reset() { override fun reset() {
antiAlias?.reset()
buffer.reset() buffer.reset()
synchronized(retiredAudio) { retiredAudio.clear() }
_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
)
}
}
@@ -0,0 +1,195 @@
/*
* 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.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Low-pass filter applied before decimating to the model's sample rate.
*
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
* interpolating between samples, with nothing removing the content above the new
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
* of hiss folds down on top of the signal and lifts the noise floor across the entire
* display.
*
* The resampler itself is deliberately left alone: its comment notes it matches the
* reference implementation the model was trained against, so changing its arithmetic
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
* aliasing without touching that contract.
*
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
* smear the keying envelope the way a high-order IIR would.
*/
object CwAntiAlias {
/**
* Cut-off as a fraction of the target Nyquist.
*
* Below 1.0 so the transition band lands inside the discarded region rather than
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
* model's 1200 Hz window and the shifter's detection range with room to spare.
*/
private const val CUTOFF_FRACTION = 0.92
/**
* Filter length. Odd so the group delay is a whole number of samples.
*
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
* be sharper and slower; this runs on a phone during a pass.
*/
private const val TAPS = 127
/** Delay introduced by [TAPS], for callers that need to align another path. */
const val GROUP_DELAY_SAMPLES = TAPS / 2
/**
* Filter [audio] so that decimating to [targetRate] cannot alias.
*
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
*/
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
if (audio.isEmpty() || sourceRate <= targetRate) return audio
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
}
/**
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
*
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
* survives.
*/
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
val normalised = cutoffHz / sampleRate
val half = TAPS / 2
val raw = DoubleArray(TAPS) { i ->
val n = i - half
val sinc = if (n == 0) {
2.0 * normalised
} else {
sin(2.0 * PI * normalised * n) / (PI * n)
}
val window = 0.42 -
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
0.08 * cos(4.0 * PI * i / (TAPS - 1))
sinc * window
}
val sum = raw.sum()
// Unity DC gain, so filtering does not change the level the model was trained on.
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
}
/**
* Convolve, compensating for the filter's own delay so the output lines up with
* the input. Edge taps that fall outside the buffer see zeros, which costs the
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
*/
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
val out = FloatArray(audio.size)
for (i in audio.indices) {
var sum = 0f
for (k in kernel.indices) {
val j = i - k + GROUP_DELAY_SAMPLES
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
}
out[i] = sum
}
return out
}
/**
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
*
* Two things are needed for concatenated chunks to match a whole-buffer filter.
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
* outputs need the tail of the one before it.
*
* The second is less obvious and was measured rather than reasoned about. A
* linear-phase FIR is centred, so output sample `i` needs input up to
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
* last 44 samples of every chunk, which is a click at each boundary rather than a
* rounding difference.
*
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
* against a 20 WPM dot of roughly 60 ms.
*
* Not thread-safe: driven from the single capture coroutine.
*/
class Streaming(sourceRate: Int, targetRate: Int) {
private val kernel: FloatArray? =
if (sourceRate <= targetRate) {
null
} else {
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
}
/** Samples not yet emitted: filter history plus the lookahead still owed. */
private var pending = FloatArray(0)
/** Filter one chunk, continuing from the previous call. */
fun process(chunk: FloatArray): FloatArray {
val k = kernel ?: return chunk
if (chunk.isEmpty()) return chunk
val combined = FloatArray(pending.size + chunk.size)
pending.copyInto(combined)
chunk.copyInto(combined, pending.size)
// Only samples with a full window on both sides are ready. Everything from
// here on still needs input that has not arrived.
val ready = combined.size - TAPS + 1
if (ready <= 0) {
pending = combined
return FloatArray(0)
}
val out = FloatArray(ready)
for (i in 0 until ready) {
var sum = 0f
for (t in k.indices) {
sum += k[t] * combined[i + TAPS - 1 - t]
}
out[i] = sum
}
// Carry the tail that the next chunk will complete.
pending = combined.copyOfRange(ready, combined.size)
return out
}
/** Clear pending state, e.g. after a decoder reset. */
fun reset() {
pending = FloatArray(0)
}
}
}
@@ -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)
@@ -0,0 +1,198 @@
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.hypot
import kotlin.math.sin
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, which reads as a real signal, and the whole band above
* 1600 Hz folded down and lifted the noise floor across the display.
*/
class CwAntiAliasTest {
private val captureRate = 44100
private val targetRate = CwDeepSpectrogram.SAMPLE_RATE
private val nyquist = targetRate / 2.0
/** Magnitude at one frequency, Hann-windowed so neighbours do not smear in. */
private fun magnitudeAt(signal: FloatArray, hz: Double, rate: Int): Double {
val n = signal.size
var real = 0.0
var imag = 0.0
val omega = 2.0 * PI * hz / rate
for (i in 0 until n) {
val window = 0.5 - 0.5 * kotlin.math.cos(2.0 * PI * i / (n - 1))
val value = signal[i] * window
real += value * kotlin.math.cos(omega * i)
imag -= value * sin(omega * i)
}
return hypot(real, imag) / n
}
private fun tone(hz: Double, rate: Int, seconds: Double = 0.4): FloatArray {
val n = (rate * seconds).toInt()
return FloatArray(n) { i -> sin(2.0 * PI * hz * i / rate).toFloat() }
}
/** A tone the model needs must survive the filter. */
@Test
fun `tones inside the window pass through`() {
for (hz in listOf(300.0, 700.0, 1000.0, 1200.0)) {
val clean = tone(hz, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(clean, captureRate, targetRate)
val before = magnitudeAt(clean, hz, captureRate)
val after = magnitudeAt(filtered, hz, captureRate)
assertTrue(
"$hz Hz lost too much: $before -> $after",
after > before * 0.7
)
}
}
/**
* The defect. 3000 Hz used to fold to 200 Hz and look like a strong signal; the filter has to
* remove it before the resampler can alias it.
*/
@Test
fun `tones that would alias are suppressed`() {
// Limits are the measured response, not aspirations. 1800 Hz sits just past the 1472 Hz
// cut-off, and 127 taps cannot give a steeper transition than that - a longer filter would
// be sharper and slower, and this runs on a phone during a pass. What matters is that the
// wideband hiss well above the window, which is what smears across the whole display, is
// gone: 2400 Hz and up measure below a thousandth.
val cases = listOf(
Triple(1800.0, 1400.0, 0.20),
Triple(2400.0, 800.0, 0.01),
Triple(3000.0, 200.0, 0.01),
Triple(5000.0, 1400.0, 0.01)
)
for ((source, foldedTo, limit) in cases) {
val raw = tone(source, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(raw, captureRate, targetRate)
val aliasedRaw = CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate)
val aliasedFiltered = CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate)
val ghostBefore = magnitudeAt(aliasedRaw, foldedTo, targetRate)
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
assertTrue(
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter",
ghostAfter < ghostBefore * limit
)
}
}
/** Nothing above the target Nyquist means nothing to do. */
@Test
fun `no filtering when the rate is already low enough`() {
val audio = tone(700.0, targetRate)
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, targetRate, targetRate))
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, 2000, targetRate))
}
@Test
fun `an empty buffer is returned unchanged`() {
val empty = FloatArray(0)
assertSame(empty, CwAntiAlias.prepareForDecimation(empty, captureRate, targetRate))
}
/** Unity DC gain: filtering must not move the level the model was trained on. */
@Test
fun `a steady level is preserved`() {
val flat = FloatArray(4410) { 0.5f }
val filtered = CwAntiAlias.prepareForDecimation(flat, captureRate, targetRate)
// Skip the edges, where taps fall outside the buffer.
val middle = filtered.copyOfRange(
CwAntiAlias.GROUP_DELAY_SAMPLES + 1,
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
)
for (v in middle) {
assertTrue("level drifted to $v", kotlin.math.abs(v - 0.5f) < 0.02f)
}
}
/**
* Chunked filtering has to match whole-buffer filtering, or every chunk boundary becomes a
* click - the same failure the tone shifter's streaming path exists to prevent.
*/
/**
* Chunked filtering must equal whole-buffer filtering exactly, or every chunk boundary is a
* click. A first attempt let the lookahead taps read zeros at the end of each chunk and
* diverged by 0.134 across the last 44 samples of every one; holding output back by the group
* delay makes the two identical.
*/
@Test
fun `streaming matches whole-buffer filtering`() {
val audio = tone(700.0, captureRate, seconds = 0.3)
val whole = CwAntiAlias.prepareForDecimation(audio, captureRate, targetRate)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < audio.size) {
val end = minOf(offset + chunkSize, audio.size)
pieces += streaming.process(audio.copyOfRange(offset, end)).toList()
offset = end
}
val streamed = pieces.toFloatArray()
// Output is delayed by the group delay, so streamed[i] corresponds to whole[i + delay].
var worst = 0f
for (i in streamed.indices) {
val j = i + CwAntiAlias.GROUP_DELAY_SAMPLES
if (j >= whole.size) break
val diff = kotlin.math.abs(streamed[i] - whole[j])
if (diff > worst) worst = diff
}
assertTrue("streaming diverges by $worst", worst < 1e-5f)
}
/** Streaming must not lift the noise floor either. */
@Test
fun `streaming suppresses an aliasing tone too`() {
val raw = tone(3000.0, captureRate, seconds = 0.3)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < raw.size) {
val end = minOf(offset + chunkSize, raw.size)
pieces += streaming.process(raw.copyOfRange(offset, end)).toList()
offset = end
}
val filtered = pieces.toFloatArray()
val ghostBefore = magnitudeAt(
CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate), 200.0, targetRate
)
val ghostAfter = magnitudeAt(
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
)
assertTrue("ghost survived: $ghostBefore -> $ghostAfter", ghostAfter < ghostBefore * 0.01)
}
/** Reset has to clear history, or the next session starts with the last one's tail. */
@Test
fun `reset clears the history`() {
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val loud = FloatArray(4410) { 0.9f }
streaming.process(loud)
streaming.reset()
val silence = FloatArray(4410)
val after = streaming.process(silence)
for (v in after) {
assertTrue("history leaked into silence: $v", kotlin.math.abs(v) < 0.01f)
}
// And a second silent chunk, now that the pipeline is primed.
for (v in streaming.process(FloatArray(4410))) {
assertTrue("history still leaking: $v", kotlin.math.abs(v) < 0.01f)
}
}
}
@@ -39,7 +39,6 @@
<string name="aprs_toast_ok">APRS: laporan terkirim</string> <string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string> <string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string> <string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
<string name="aprs_toast_receive_only">APRS-IS menerima koneksi dalam mode terima-saja, laporan Anda tidak diteruskan. Masukkan passcode untuk mengirim.</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string> <string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string> <string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string> <string name="prefs_aprs_last_ok">OK</string>
@@ -39,7 +39,6 @@
<string name="aprs_toast_ok">APRS: laporan terkirim</string> <string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string> <string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string> <string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
<string name="aprs_toast_receive_only">APRS-IS menerima koneksi dalam mode terima-saja, laporan Anda tidak diteruskan. Masukkan passcode untuk mengirim.</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string> <string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string> <string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string> <string name="prefs_aprs_last_ok">OK</string>
@@ -40,7 +40,6 @@
<string name="aprs_toast_ok">APRS: rapor gönderildi</string> <string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string> <string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS passcode bilginizi doğrulamadı. Raporlarınız iletilmiyor - Ayarlar bölümünde çağrı işareti ve passcode bilgisini kontrol edin.</string> <string name="aprs_toast_unverified">APRS-IS passcode bilginizi doğrulamadı. Raporlarınız iletilmiyor - Ayarlar bölümünde çağrı işareti ve passcode bilgisini kontrol edin.</string>
<string name="aprs_toast_receive_only">APRS-IS baglantiyi yalnizca alma modunda kabul etti, raporlariniz iletilmiyor. Gondermek icin passcode girin.</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string> <string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string> <string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string> <string name="prefs_aprs_last_ok">OK</string>
@@ -58,7 +58,6 @@
<string name="aprs_toast_ok">APRS: 上报成功</string> <string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string> <string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS 未验证你的 passcode, 上报不会被转发 - 请在设置里检查呼号与 passcode。</string> <string name="aprs_toast_unverified">APRS-IS 未验证你的 passcode, 上报不会被转发 - 请在设置里检查呼号与 passcode。</string>
<string name="aprs_toast_receive_only">APRS-IS 以只收模式接受了连接, 上报不会被转发。填入 passcode 才能发射。</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string> <string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string> <string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string> <string name="prefs_aprs_last_ok">成功</string>
@@ -59,7 +59,6 @@
<string name="aprs_toast_ok">APRS: report sent OK</string> <string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string> <string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS did not verify your passcode. Your reports are not being passed on - check the callsign and passcode in Settings.</string> <string name="aprs_toast_unverified">APRS-IS did not verify your passcode. Your reports are not being passed on - check the callsign and passcode in Settings.</string>
<string name="aprs_toast_receive_only">APRS-IS accepted the connection in receive-only mode, so your reports are not forwarded. Enter your passcode to transmit.</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string> <string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string> <string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string> <string name="prefs_aprs_last_ok">OK</string>
@@ -51,14 +51,18 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import kotlinx.coroutines.launch
import androidx.compose.ui.draw.rotate
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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
@@ -69,14 +73,6 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R as CoreR import com.rtbishop.look4sat.core.presentation.R as CoreR
import kotlin.math.roundToInt 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.
*/
private const val AUTOSCROLL_SLACK_PX = 4
/** /**
* Full-page CW decoder backed by DeepCW. * Full-page CW decoder backed by DeepCW.
* *
@@ -103,6 +99,16 @@ fun CwDecodeScreen() {
} }
var permanentlyDenied by remember { mutableStateOf(false) } var permanentlyDenied by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) } var isListening by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 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 decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState() val historyText by decoder.historyText.collectAsState()
@@ -125,7 +131,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
@@ -138,10 +150,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(
@@ -157,6 +175,41 @@ fun CwDecodeScreen() {
.weight(1f) .weight(1f)
.padding(start = 12.dp) .padding(start = 12.dp)
) )
// 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 (copyable.isNotEmpty()) {
clipboard.setText(AnnotatedString(copyable))
showToast(copiedMessage)
}
},
enabled = copyable.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) } }) {
Icon(
// The shared arrow points right; rotated to point down. Adding a second
// drawable for the same shape is how icon sets start to drift.
painter = painterResource(CoreR.drawable.ic_arrow),
contentDescription = stringResource(R.string.cw_scroll_newest),
modifier = Modifier.rotate(90f)
)
}
IconButton(onClick = { isListening = !isListening }) { IconButton(onClick = { isListening = !isListening }) {
Icon( Icon(
painter = painterResource( painter = painterResource(
@@ -223,6 +276,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)
@@ -231,35 +294,25 @@ 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,
val scroll = rememberScrollState() // which the model rewrites from scratch every 1.5 seconds as more context arrives -
// Follow the newest text, but stop as soon as the operator scrolls away, so // right for the line above, where the newest guess is what you want, but it meant the
// reading back over earlier traffic is not undone by the next decode. // 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.
// //
// A boolean rather than comparing position against maxValue: maxValue is // It used to auto-scroll to the bottom on every decode, dropping out of follow mode
// written during layout, after the composition that would read it, so such a // only once the operator had already fought it by scrolling away. Now it stays where
// comparison tests the previous frame's height and drifts short of the true // it was put, and the button below jumps to the newest text on request.
// bottom until it latches out of follow-mode altogether.
var following by remember { mutableStateOf(true) }
LaunchedEffect(scroll) {
snapshotFlow { scroll.isScrollInProgress to scroll.value }
.collect { (scrolling, value) ->
if (scrolling) following = value >= scroll.maxValue - AUTOSCROLL_SLACK_PX
}
}
LaunchedEffect(transcript, following) {
if (!following) return@LaunchedEffect
// Twice: the first pass lands at the height known when it started, the
// second covers growth that arrived while it was animating.
repeat(2) {
if (scroll.value < scroll.maxValue) scroll.animateScrollTo(scroll.maxValue)
}
}
Text( Text(
text = transcript, text = transcript,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scroll) .verticalScroll(transcriptScroll)
.padding(8.dp), .padding(8.dp),
fontSize = 16.sp, fontSize = 16.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -58,6 +59,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
// Compose draw thread, so every touch of these two collections is guarded. // Compose draw thread, so every touch of these two collections is guarded.
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws. // ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
private val lock = Any() private val lock = Any()
/** Anti-alias filter for the decimation, built once the capture rate is known. */
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
private val rows = ArrayDeque<FloatArray>(historyRows) private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE) private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
// Incremented by clear(). A pushSamples call records the generation before // Incremented by clear(). A pushSamples call records the generation before
@@ -80,8 +85,21 @@ class CwWaterfallState(private val historyRows: Int = 96) {
fun pushSamples(chunk: FloatArray, sampleRate: Int) { fun pushSamples(chunk: FloatArray, sampleRate: Int) {
if (chunk.isEmpty()) return if (chunk.isEmpty()) return
// Band-limit before decimating, for the same reason the decoder does: without it
// everything above 1600 Hz folds into the display. That is not a cosmetic problem -
// the entire 1600-22050 Hz band of hiss lands on top of the signal and smears across
// the whole waterfall, and a strong out-of-band tone appears as a convincing ghost at
// a frequency nothing is transmitting on.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(chunk) ?: chunk
// The filter holds back its group delay, so the first call yields nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear( val resampled = CwDeepSpectrogram.resampleLinear(
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
) )
val audio: FloatArray val audio: FloatArray
val generationAtStart: Long val generationAtStart: Long
@@ -120,6 +138,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
} }
fun clear() { fun clear() {
antiAlias?.reset()
synchronized(lock) { synchronized(lock) {
generation++ generation++
rows.clear() rows.clear()
@@ -3,6 +3,7 @@
<string name="cw_pause">Jeda pendekodean</string> <string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string> <string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string> <string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string> <string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string> <string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string> <string name="cw_open_settings">Buka pengaturan aplikasi</string>
@@ -13,4 +14,8 @@
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string> <string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string> <string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string> <string name="cw_waterfall_inside">nada %1$d hertz</string>
<string name="cw_copy">Salin catatan</string>
<string name="cw_copied">Catatan disalin</string>
<string name="cw_record_label">Catatan</string>
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
</resources> </resources>
@@ -3,6 +3,7 @@
<string name="cw_pause">Jeda pendekodean</string> <string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string> <string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string> <string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string> <string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string> <string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string> <string name="cw_open_settings">Buka pengaturan aplikasi</string>
@@ -13,4 +14,8 @@
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string> <string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string> <string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string> <string name="cw_waterfall_inside">nada %1$d hertz</string>
<string name="cw_copy">Salin catatan</string>
<string name="cw_copied">Catatan disalin</string>
<string name="cw_record_label">Catatan</string>
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
</resources> </resources>
@@ -3,6 +3,7 @@
<string name="cw_pause">Çözmeyi duraklat</string> <string name="cw_pause">Çözmeyi duraklat</string>
<string name="cw_resume">Çözmeyi sürdür</string> <string name="cw_resume">Çözmeyi sürdür</string>
<string name="cw_clear">Çözülen metni temizle</string> <string name="cw_clear">Çözülen metni temizle</string>
<string name="cw_scroll_newest">En yeni metne git</string>
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string> <string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
<string name="cw_grant_permission">İzin ver</string> <string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string> <string name="cw_open_settings">Uygulama ayarlarını aç</string>
@@ -13,4 +14,8 @@
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string> <string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string> <string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
<string name="cw_waterfall_inside">ton %1$d hertz</string> <string name="cw_waterfall_inside">ton %1$d hertz</string>
<string name="cw_copy">Kaydı kopyala</string>
<string name="cw_copied">Kayıt kopyalandı</string>
<string name="cw_record_label">Kayıt</string>
<string name="cw_record_empty">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.</string>
</resources> </resources>
@@ -3,6 +3,7 @@
<string name="cw_pause">暂停解码</string> <string name="cw_pause">暂停解码</string>
<string name="cw_resume">继续解码</string> <string name="cw_resume">继续解码</string>
<string name="cw_clear">清空解码文本</string> <string name="cw_clear">清空解码文本</string>
<string name="cw_scroll_newest">跳到最新</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string> <string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string> <string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string> <string name="cw_open_settings">打开应用设置</string>
@@ -13,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>
@@ -3,6 +3,7 @@
<string name="cw_pause">Pause decoding</string> <string name="cw_pause">Pause decoding</string>
<string name="cw_resume">Resume decoding</string> <string name="cw_resume">Resume decoding</string>
<string name="cw_clear">Clear decoded text</string> <string name="cw_clear">Clear decoded text</string>
<string name="cw_scroll_newest">Jump to the newest text</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string> <string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string> <string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string> <string name="cw_open_settings">Open app settings</string>
@@ -13,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">Text lands here once a batch of audio has been decoded. The line above updates sooner. Copy before leaving: the record is not stored.</string>
</resources> </resources>
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "468" appVersionCode = "469"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.6.1" appVersionName = "4.6.2"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry