Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb73ffef7 | ||
|
|
cc4f156c83 | ||
|
|
3810e76ea2 | ||
|
|
5ed76dba31 | ||
|
|
78a6f270bf | ||
|
|
8445c17033 |
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user