fix(cw): drop stale audio on a tone-shift change, and damp detector jitter
Follow-up to the tone-shift feature, closing gaps the audits surfaced. Toggling the setting, or the detector settling on a materially different shift, now discards the buffered audio. Without it the 20 s decode window kept feeding the model samples moved by the old amount for up to 20 s after the user acted, and updateSignalMetrics corrected the pitch readout by an offset that no longer matched the window. Text already committed to the history is kept: it was correct when it was decoded. The previous-state flag is nullable and seeded from the current setting on the first chunk, so a decoder created while the setting is already on does not report a spurious change and wipe an empty buffer. reset() clears it back to null for the same reason. Two decoders can be live at once (the CW screen and the Radar panel) and each tracks its own state. Re-shifting is now gated by a 40 Hz hysteresis. Detection resolution is 12.5 Hz and a real tone wanders, so without it an estimate hopping between adjacent scan bins would drop the window every 2 s - costing far more decoding context than re-centring gains. 40 Hz absorbs two bins of jitter while still following a genuine retune; a test pins both halves of that trade-off.
This commit is contained in:
1 parent
9798107d37
commit
1b8f8c46f6
2 files changed
+97
-16
No files matched your search
@@ -36,6 +36,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
|
||||
@@ -84,6 +85,15 @@ class CwDeepDecoder(
|
||||
|
||||
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
|
||||
const val DETECT_INTERVAL_MS = 2000
|
||||
|
||||
/**
|
||||
* Minimum change in the required shift before the window is re-shifted.
|
||||
*
|
||||
* Two scan bins (12.5 Hz each) plus margin. Re-shifting drops the 20 s decode
|
||||
* window, so a tone drifting slightly - or the estimate hopping to an adjacent
|
||||
* bin - must not keep wiping context that is still perfectly decodable.
|
||||
*/
|
||||
const val SHIFT_HYSTERESIS_HZ = 40f
|
||||
}
|
||||
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
@@ -139,8 +149,12 @@ class CwDeepDecoder(
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/** Previous value of the setting, so a toggle can invalidate buffered audio. */
|
||||
private var toneShiftWasEnabled = false
|
||||
/**
|
||||
* 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
|
||||
* must not treat that as a change and wipe an empty buffer.
|
||||
*/
|
||||
private var toneShiftWasEnabled: Boolean? = null
|
||||
|
||||
private var environment: OrtEnvironment? = null
|
||||
private var session: OrtSession? = null
|
||||
@@ -298,19 +312,27 @@ class CwDeepDecoder(
|
||||
* @return the audio to buffer: [resampled] itself whenever no shift applies.
|
||||
*/
|
||||
private fun applyToneShift(resampled: FloatArray): FloatArray {
|
||||
if (!isToneShiftEnabled()) {
|
||||
// Clear stale state so re-enabling starts from a fresh detection.
|
||||
if (activeShiftHz != 0f || detectedToneHz != null || detectFill > 0) {
|
||||
Log.i(TAG, "toneShift: disabled, clearing shift=${activeShiftHz}Hz")
|
||||
activeShiftHz = 0f
|
||||
detectedToneHz = null
|
||||
lastDetectAtMs = 0L
|
||||
detectFill = 0
|
||||
streamingShifter.reset()
|
||||
}
|
||||
return resampled
|
||||
val enabled = isToneShiftEnabled()
|
||||
|
||||
// A toggle invalidates whatever is already buffered: those samples were moved by
|
||||
// the old setting and cannot be un-shifted, so the 20 s window would keep
|
||||
// decoding them - and the pitch readout would correct them by the wrong amount -
|
||||
// for up to 20 s after the user acted. Seeded from the current setting on the
|
||||
// first chunk so starting up with it already on is not treated as a change.
|
||||
val previousEnabled = toneShiftWasEnabled ?: enabled
|
||||
toneShiftWasEnabled = enabled
|
||||
if (enabled != previousEnabled) {
|
||||
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
|
||||
dropBufferedAudio()
|
||||
activeShiftHz = 0f
|
||||
detectedToneHz = null
|
||||
lastDetectAtMs = 0L
|
||||
detectFill = 0
|
||||
streamingShifter.reset()
|
||||
}
|
||||
|
||||
if (!enabled) return resampled
|
||||
|
||||
accumulateForDetection(resampled)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -342,12 +364,33 @@ class CwDeepDecoder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard buffered audio that was shifted by a now-stale amount.
|
||||
*
|
||||
* The live window and the pending archive chunk both hold shifted samples that
|
||||
* 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.
|
||||
*/
|
||||
private fun dropBufferedAudio() {
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
}
|
||||
|
||||
/** Update [activeShiftHz] from a detection pass and log what was decided. */
|
||||
private fun runDetection(sample: FloatArray) {
|
||||
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
val previousShift = activeShiftHz
|
||||
|
||||
// Ignore small changes. Dropping the window costs 20 s of context, so a tone
|
||||
// wandering by a few Hz - or a detector estimate landing on an adjacent 12.5 Hz
|
||||
// scan bin - must not keep wiping it. Re-shifting only pays off once the tone
|
||||
// has moved enough to matter against the 800 Hz window centre.
|
||||
val keepPreviousShift = previousShift != 0f &&
|
||||
analysis.needsShift &&
|
||||
abs(analysis.shiftHz - previousShift) < SHIFT_HYSTERESIS_HZ
|
||||
|
||||
detectedToneHz = analysis.toneHz
|
||||
activeShiftHz = analysis.shiftHz
|
||||
if (!keepPreviousShift) activeShiftHz = analysis.shiftHz
|
||||
|
||||
when {
|
||||
analysis.toneHz == null ->
|
||||
@@ -375,8 +418,7 @@ class CwDeepDecoder(
|
||||
"toneShift: shift changed ${previousShift}Hz -> ${activeShiftHz}Hz, " +
|
||||
"dropping buffered audio"
|
||||
)
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
dropBufferedAudio()
|
||||
streamingShifter.reset()
|
||||
CwProbe.step("tone_shift tone=${analysis.toneHz} shift=$activeShiftHz")
|
||||
}
|
||||
@@ -489,6 +531,9 @@ class CwDeepDecoder(
|
||||
lastDetectAtMs = 0L
|
||||
detectFill = 0
|
||||
streamingShifter.reset()
|
||||
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
|
||||
// current setting instead of reporting a spurious change.
|
||||
toneShiftWasEnabled = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
@@ -125,4 +126,39 @@ class CwToneShiftGateTest {
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tone drifting slightly, or a detector estimate hopping to an adjacent 12.5 Hz
|
||||
* scan bin, must not keep re-shifting: the decoder drops its 20 s window on every
|
||||
* shift change, so churn would cost more context than the re-centring gains.
|
||||
*
|
||||
* This asserts the hysteresis threshold the decoder applies is wide enough to
|
||||
* absorb realistic detector jitter and narrow enough to still follow a real retune.
|
||||
*/
|
||||
@Test
|
||||
fun `hysteresis absorbs detector jitter but follows a real retune`() {
|
||||
val hysteresisHz = 40f // CwDeepDecoder.SHIFT_HYSTERESIS_HZ
|
||||
val scanStepHz = 12.5f // CwToneShifter's detection resolution
|
||||
|
||||
assertTrue(
|
||||
"hysteresis must cover at least two scan bins of jitter",
|
||||
hysteresisHz >= scanStepHz * 2
|
||||
)
|
||||
|
||||
// Jitter: successive estimates one or two bins apart produce shifts that differ
|
||||
// by less than the threshold, so the decoder keeps the shift it already has.
|
||||
val shiftAt1400 = (CwToneShifter.TARGET_HZ - 1400.0).toFloat()
|
||||
val shiftAt1412 = (CwToneShifter.TARGET_HZ - 1412.5).toFloat()
|
||||
assertTrue(
|
||||
"a one-bin estimate hop must not trigger a re-shift",
|
||||
abs(shiftAt1412 - shiftAt1400) < hysteresisHz
|
||||
)
|
||||
|
||||
// A real retune moves the tone far enough that re-centring is worth the reset.
|
||||
val shiftAt1300 = (CwToneShifter.TARGET_HZ - 1300.0).toFloat()
|
||||
assertTrue(
|
||||
"a 100 Hz retune must still be followed",
|
||||
abs(shiftAt1300 - shiftAt1400) >= hysteresisHz
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user