refactor(cw): move the shift decision into core:domain so tests can reach it

Mutation testing found the decision rule was effectively untested. Four defects
injected into it - removing the silence guard, comparing shifts instead of
tones, never setting the hysteresis anchor, and inverting the comparison - all
left the entire suite green. The rule lived inside CwDeepDecoder, which needs an
Android Context and a loaded ONNX session, so tests could only restate it, and a
restated rule cannot fail when the real one is wrong.

CwShiftDecider now holds the rule as a pure class that both the decoder and the
tests drive. Its outcome is reported as an enum so the decoder's logging is a
presentation concern rather than a second copy of the logic. CwShiftDeciderTest
targets each of the four surviving mutants directly.

MIN_PROMINENCE lowered from 8.0 to 4.5. Raising it to 8.0 last round overshot:
measured on 400 ms windows of keyed CW in noise, a comfortably copyable signal
reaches only 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB, so 8.0 silently refused
to shift weak out-of-window signals - the exact failure the feature exists to
prevent. Pure noise peaks at 2.2-3.4, so 4.5 keeps zero false positives across
40 noise windows while retaining the weak end. A false tone is worse than a
missed one: it moves a good signal out of range, whereas a miss leaves the audio
alone until a stronger window arrives. Windows dominated by keying gaps measure
2.4 and are indistinguishable from noise at any threshold; those are skipped.

Test files reorganised to match: the decision rule is covered by
CwShiftDeciderTest against real code, signal-level properties by
CwToneShiftSignalTest, and the restated-logic file it replaces is gone.

80 CW tests pass, golden vectors included.
This commit is contained in:
mckero committed 2026-08-22 04:02:28 +00:00
1 parent fdb44af9ff
commit ea125d7db4
7 files changed
+595 -334

No files matched your search

@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
import kotlinx.coroutines.CancellationException
@@ -37,7 +38,6 @@ 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
@@ -133,12 +133,8 @@ class CwDeepDecoder(
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
private var activeShiftHz = 0f
/**
* Tone that produced [activeShiftHz]. Hysteresis compares against this rather than
* against the previous shift, so the guard still holds where a shift flips between
* 0 and a large value - at the window edge, where the jump is largest.
*/
private var shiftAnchorToneHz: Float? = null
/** Decides what shift to apply from successive tone estimates. */
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
/** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */
private var lastDetectAtMs = 0L
@@ -328,7 +324,7 @@ class CwDeepDecoder(
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
dropBufferedAudio()
activeShiftHz = 0f
shiftAnchorToneHz = null
shiftDecider.reset()
lastDetectAtMs = 0L
detectionPool.clear()
streamingShifter.reset()
@@ -362,73 +358,51 @@ class CwDeepDecoder(
archiveSize = 0
}
/** Update [activeShiftHz] from a detection pass and log what was decided. */
/**
* Feed one detection to [shiftDecider] and log what it decided.
*
* The rule itself lives in core:domain so it can be tested directly; keeping it here
* meant tests could only restate it, and a restated rule cannot fail when the real
* one is wrong - four injected defects once left the whole suite green.
*/
private fun runDetection(sample: FloatArray) {
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
val previousShift = activeShiftHz
val decision = shiftDecider.accept(analysis)
activeShiftHz = decision.shiftHz
// Silence is absence of evidence, not evidence of a 0 Hz shift. CW keying leaves
// gaps, and a detection window landing in one used to collapse an established
// shift: measured over 180 s of keyed audio at 1400 Hz, 11 of 90 detections saw
// no tone, each wiping the window and leaving the next ~2 s buffered unshifted -
// outside the model's range, so invisible to it.
val toneHz = analysis.toneHz
if (toneHz == null) {
Log.d(
when (decision.outcome) {
CwShiftDecider.Outcome.NO_TONE -> Log.d(
TAG,
"toneShift: no tone in ${sample.size} samples, keeping shift=${previousShift}Hz"
"toneShift: no tone in ${sample.size} samples, keeping shift=${decision.shiftHz}Hz"
)
return
}
// Hysteresis in tone space, anchored on the pitch that produced the active shift.
// Comparing shifts instead let the guard lapse exactly where the jump is largest:
// at the window edge one 12.5 Hz estimate hop flips between "inside" (shift 0) and
// "outside" (a large shift), and a shift of 0 is a real state rather than no state.
// Measured before this change: a 1205 Hz tone dropped the window 35 times in 60
// detections. The tone must now clear the edge by the margin before the decoder
// changes its mind.
val anchorTone = shiftAnchorToneHz
if (anchorTone != null && abs(toneHz - anchorTone) < SHIFT_HYSTERESIS_HZ) {
// Say so explicitly: a log showing a drifting tone against an unchanged shift
// otherwise looks like the detector is being ignored.
Log.d(
CwShiftDecider.Outcome.WITHIN_HYSTERESIS -> Log.d(
TAG,
"toneShift: tone=${toneHz}Hz within ${SHIFT_HYSTERESIS_HZ}Hz of " +
"${anchorTone}Hz, keeping shift=${previousShift}Hz"
"toneShift: tone=${decision.toneHz}Hz within ${CwShiftDecider.DEFAULT_HYSTERESIS_HZ}Hz " +
"of anchor ${shiftDecider.anchorToneHz}Hz, keeping shift=${decision.shiftHz}Hz"
)
return
}
activeShiftHz = analysis.shiftHz
shiftAnchorToneHz = toneHz
if (!analysis.needsShift) {
Log.d(
CwShiftDecider.Outcome.NO_SHIFT_NEEDED -> Log.d(
TAG,
"toneShift: tone=${toneHz}Hz inside " +
"toneShift: tone=${decision.toneHz}Hz inside " +
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
)
} else {
Log.i(
CwShiftDecider.Outcome.SHIFTED -> Log.i(
TAG,
"toneShift: tone=${toneHz}Hz outside window, " +
"shifting ${analysis.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
"toneShift: tone=${decision.toneHz}Hz outside window, " +
"shifting ${decision.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
)
}
if (previousShift != activeShiftHz) {
if (decision.changed) {
// The window still holds audio moved by the old amount. Mixing two shifts in
// one spectrogram smears the tone, and the pitch readout could only be right
// for one of them, so rebuild the window from the new shift.
Log.i(
TAG,
"toneShift: shift changed ${previousShift}Hz -> ${activeShiftHz}Hz, " +
"dropping buffered audio"
)
Log.i(TAG, "toneShift: shift changed, dropping buffered audio")
dropBufferedAudio()
streamingShifter.reset()
CwProbe.step("tone_shift tone=${analysis.toneHz} shift=$activeShiftHz")
CwProbe.step("tone_shift tone=${decision.toneHz} shift=${decision.shiftHz}")
}
}
@@ -535,7 +509,7 @@ class CwDeepDecoder(
_lastInferenceMs.value = 0
// Re-detect from scratch: the operator may have retuned before resetting.
activeShiftHz = 0f
shiftAnchorToneHz = null
shiftDecider.reset()
lastDetectAtMs = 0L
detectionPool.clear()
streamingShifter.reset()
@@ -127,39 +127,4 @@ class CwToneShiftGateTest {
)
}
/**
* 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
)
}
}
@@ -0,0 +1,115 @@
/*
* 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.abs
/**
* Decides what shift to apply from a sequence of tone estimates.
*
* Kept out of the decoder so the rule can be exercised directly. The decoder needs an
* Android Context and a loaded ONNX session, so a rule living inside it can only be
* tested by restating it - and a restated rule cannot fail when the real one is wrong.
* Mutation testing proved that: four defects injected into an in-decoder version of this
* logic left the whole suite green.
*
* @param hysteresisHz how far the tone must move before the shift is revised.
*/
class CwShiftDecider(private val hysteresisHz: Float = DEFAULT_HYSTERESIS_HZ) {
companion object {
/**
* Default margin before re-shifting, in Hz.
*
* Detection resolves to 12.5 Hz and a real tone wanders, so a couple of scan bins
* of jitter must not count as a retune: revising the shift costs the whole 20 s
* decode window, which is worth far more than perfect centring.
*/
const val DEFAULT_HYSTERESIS_HZ = 40f
}
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
var shiftHz: Float = 0f
private set
/**
* Tone that produced [shiftHz]. Hysteresis compares against this rather than against
* the previous shift, because a shift of 0 is a real state: at the window edge one
* 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large
* shift), and a shift-space comparison lapses exactly where the jump is largest.
*/
var anchorToneHz: Float? = null
private set
/** What [accept] decided, for logging. */
enum class Outcome {
/** No tone in the window; the existing shift was retained. */
NO_TONE,
/** The tone moved less than the margin; the existing shift was retained. */
WITHIN_HYSTERESIS,
/** The tone is inside the model window, so no shift is needed. */
NO_SHIFT_NEEDED,
/** The shift was updated to move an out-of-window tone into range. */
SHIFTED
}
/** Result of feeding one detection to the decider. */
data class Decision(
val outcome: Outcome,
/** Shift in force after the decision. */
val shiftHz: Float,
/** True when [shiftHz] differs from the value before this decision. */
val changed: Boolean,
/** Tone the decision was based on, null when none was detected. */
val toneHz: Float?
)
/**
* Feed one tone analysis and get the shift to apply.
*
* Silence retains the current shift rather than clearing it: CW is keyed, so a
* detection window landing in a gap carries no information about the pitch. Treating
* it as an authoritative "no shift" collapsed established shifts - measured over
* 180 s of keyed audio at 1400 Hz, 11 of 90 windows saw no tone, and each one left
* the following audio unshifted and therefore invisible to the model.
*/
fun accept(analysis: CwToneShifter.Analysis): Decision {
val previousShift = shiftHz
val toneHz = analysis.toneHz
?: return Decision(Outcome.NO_TONE, previousShift, changed = false, toneHz = null)
val anchor = anchorToneHz
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) {
return Decision(Outcome.WITHIN_HYSTERESIS, previousShift, changed = false, toneHz = toneHz)
}
shiftHz = analysis.shiftHz
anchorToneHz = toneHz
val outcome = if (analysis.needsShift) Outcome.SHIFTED else Outcome.NO_SHIFT_NEEDED
return Decision(outcome, shiftHz, changed = shiftHz != previousShift, toneHz = toneHz)
}
/** Forget the current shift and anchor, e.g. when the feature is toggled or reset. */
fun reset() {
shiftHz = 0f
anchorToneHz = null
}
}
@@ -62,13 +62,20 @@ object CwToneShifter {
/**
* A detected peak must exceed the spectrum mean by this factor to count as a tone.
*
* Measured on 1280-sample windows: pure noise peaks at 2.0-3.3 times its own mean,
* while keyed CW at a usable level reaches 47-51. A threshold of 3.0 therefore let
* roughly one noise window in five through as a "tone", and a false tone is worse
* than none - it moves a perfectly good signal out of the model's range. 8.0 clears
* the noise ceiling with margin while staying far below any real signal.
* Chosen from measurements on 1280-sample (400 ms) windows of keyed CW in noise.
* Pure noise peaks at 2.2-3.4 times its own spectral mean, so 3.0 admitted roughly
* one noise window in five. Raising it as far as 8.0 then rejected comfortably
* copyable signals: keyed CW measures 7.6-9.0 at 0 dB SNR and only 5.2-6.7 at -3 dB.
*
* 4.5 gives zero false positives across 40 noise windows while keeping the weaker
* end of usable signals. The asymmetry is deliberate: a false tone is worse than a
* missed one, because it moves a perfectly good signal out of the model's range,
* whereas a miss just leaves the audio alone until a stronger window arrives.
*
* Windows dominated by keying gaps (a slow fist, under ~25% tone) sit at 2.4 and are
* indistinguishable from noise at any threshold; those are skipped, not guessed at.
*/
const val MIN_PROMINENCE = 8.0
const val MIN_PROMINENCE = 4.5
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
private const val HILBERT_TAPS = 63
@@ -0,0 +1,271 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse].
*
* This suite exists because an earlier version of the same rule lived inside the decoder,
* where tests could only restate it. Mutation testing then showed four injected defects -
* removing the silence guard, comparing shifts instead of tones, never setting the anchor,
* and inverting the hysteresis comparison - all left the suite green. Every test below
* targets one of those, so each is now a real tripwire.
*/
class CwShiftDeciderTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
private val hysteresisHz = CwShiftDecider.DEFAULT_HYSTERESIS_HZ
private fun steadyTone(hz: Double, samples: Int = 1280): FloatArray =
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
private fun noise(samples: Int = 1280, seed: Int = 1, level: Double = 0.02): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
private fun analyse(audio: FloatArray) = CwToneShifter.analyse(audio, sampleRate)
private fun feed(decider: CwShiftDecider, audio: FloatArray) = decider.accept(analyse(audio))
// --- Mutant (a): the silence guard ---------------------------------------------
@Test
fun `silence retains an established shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome)
assertTrue("a 1400 Hz tone must produce a shift", established.shiftHz != 0f)
val silent = feed(decider, noise())
assertEquals(
"silence must be reported as no tone, not as a zero shift",
CwShiftDecider.Outcome.NO_TONE, silent.outcome
)
assertEquals(
"silence must not change the shift",
established.shiftHz, silent.shiftHz, 0f
)
assertFalse("a silent window is not a change", silent.changed)
assertEquals(
"the decider's state must still hold the shift",
established.shiftHz, decider.shiftHz, 0f
)
}
@Test
fun `a run of silence does not erode the shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0)).shiftHz
repeat(8) { i ->
val decision = feed(decider, noise(seed = i + 2))
assertEquals(
"silent window $i changed the shift",
established, decision.shiftHz, 0f
)
}
assertEquals(established, decider.shiftHz, 0f)
assertNotNull("the anchor must survive silence", decider.anchorToneHz)
}
// --- Mutants (b) and (c): hysteresis anchored on the tone ----------------------
@Test
fun `an estimate hopping across the window edge does not re-shift`() {
// 1200.0 Hz is inside the window (shift 0); 1212.5 Hz, one scan bin away, is
// outside (a large shift). A shift-space comparison lapses here because one side
// is zero, which is exactly where the jump is largest.
val decider = CwShiftDecider()
val first = feed(decider, steadyTone(1212.5))
assertEquals(CwShiftDecider.Outcome.SHIFTED, first.outcome)
val hop = feed(decider, steadyTone(1200.0))
assertEquals(
"a one-bin hop back across the edge must be absorbed",
CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome
)
assertEquals("the shift must not move", first.shiftHz, hop.shiftHz, 0f)
assertFalse(hop.changed)
}
@Test
fun `the anchor is set from the tone that produced the shift`() {
val decider = CwShiftDecider()
assertNull("no anchor before the first detection", decider.anchorToneHz)
feed(decider, steadyTone(1400.0))
assertEquals(
"the anchor must be the detected tone",
1400.0, decider.anchorToneHz!!.toDouble(), 25.0
)
// An in-window tone must anchor too, otherwise a tone drifting from inside the
// window to outside would be measured against a stale reference.
feed(decider, steadyTone(700.0))
assertEquals(
"an in-window tone must also become the anchor",
700.0, decider.anchorToneHz!!.toDouble(), 25.0
)
assertEquals("an in-window tone needs no shift", 0f, decider.shiftHz, 0f)
}
@Test
fun `hysteresis is measured against the anchor, not the previous estimate`() {
// Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison
// against the previous estimate would never fire. Anchored, the shift updates
// once the accumulated move clears the margin.
val decider = CwShiftDecider()
feed(decider, steadyTone(1300.0))
val anchorAtStart = decider.anchorToneHz!!
var tone = 1325.0
var updates = 0
while (tone <= 1450.0) {
if (feed(decider, steadyTone(tone)).changed) updates++
tone += 25.0
}
assertTrue(
"accumulated drift must eventually re-shift; anchor started at $anchorAtStart " +
"and the shift updated $updates times",
updates >= 1
)
}
// --- Mutant (d): the comparison direction --------------------------------------
@Test
fun `a large retune is followed while small moves are absorbed`() {
val decider = CwShiftDecider()
val before = feed(decider, steadyTone(1400.0)).shiftHz
// Well inside the margin: must be absorbed.
val small = feed(decider, steadyTone(1412.5))
assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome)
assertEquals(before, small.shiftHz, 0f)
// Well beyond it: must be followed. An inverted comparison would absorb this and
// react to the small move instead.
val large = feed(decider, steadyTone(1000.0))
assertTrue(
"a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})",
large.changed
)
assertEquals(
"a 1000 Hz tone is inside the window, so no shift is needed",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome
)
assertEquals(0f, large.shiftHz, 0f)
}
@Test
fun `an edge tone settles instead of thrashing`() {
val decider = CwShiftDecider()
var changes = 0
// Estimates hopping around the 1200 Hz edge, the worst case for a shift-space rule.
val hops = listOf(1200.0, 1212.5, 1200.0, 1187.5, 1212.5, 1200.0, 1225.0, 1200.0)
repeat(4) {
for (hz in hops) {
if (feed(decider, steadyTone(hz)).changed) changes++
}
}
assertTrue(
"an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections",
changes <= 3
)
}
// --- Drift and state consistency ----------------------------------------------
@Test
fun `slow drift keeps the shifted tone inside the model window`() {
val decider = CwShiftDecider()
var tone = 1300.0
var worstOffset = 0.0
while (tone <= 1550.0) {
val decision = feed(decider, steadyTone(tone))
val landed = tone + decision.shiftHz
worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ))
assertTrue(
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window",
CwToneShifter.isInsideWindow(landed.toFloat())
)
tone += 12.5
}
assertTrue(
"staleness must stay near the margin, worst offset was $worstOffset Hz",
worstOffset <= hysteresisHz + 12.5
)
}
@Test
fun `reset clears both the shift and the anchor together`() {
val decider = CwShiftDecider()
feed(decider, steadyTone(1400.0))
assertTrue(decider.shiftHz != 0f)
assertNotNull(decider.anchorToneHz)
decider.reset()
assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f)
assertNull("reset must clear the anchor", decider.anchorToneHz)
// After a reset the next tone must be acted on rather than absorbed.
val decision = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, decision.outcome)
assertTrue(decision.changed)
}
@Test
fun `a non-zero shift always has an anchor`() {
// An inconsistent pair would make hysteresis behave differently depending on how
// the state was reached, so pin the invariant across a mixed sequence.
val decider = CwShiftDecider()
val sequence = listOf(
steadyTone(1400.0), noise(), steadyTone(1412.5), steadyTone(300.0),
noise(seed = 5), steadyTone(700.0), steadyTone(1500.0), noise(seed = 9)
)
for ((index, audio) in sequence.withIndex()) {
feed(decider, audio)
if (decider.shiftHz != 0f) {
assertNotNull(
"step $index left a shift of ${decider.shiftHz}Hz with no anchor",
decider.anchorToneHz
)
}
}
}
@Test
fun `shift always lands the tone on the target`() {
for (hz in listOf(150.0, 250.0, 300.0, 1250.0, 1400.0, 1500.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone must be shifted to the window centre",
CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0
)
}
}
@Test
fun `in-window tones are never shifted`() {
for (hz in listOf(400.0, 500.0, 800.0, 1100.0, 1200.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone is inside the window and must not be shifted",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome
)
assertEquals(0f, decision.shiftHz, 0f)
}
}
}
@@ -1,238 +0,0 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Regression guards for the two decision defects an audit measured in the decoder's
* detection loop. Both silently defeated the feature, so both are pinned here.
*
* The decoder's rule is reproduced by [decide] because `runDetection` needs a Context
* and a loaded ONNX model; the inputs it consumes ([CwToneShifter.analyse]) are the real
* thing, and the numbers below come from the same audio the audit used.
*/
class CwToneShiftDecisionTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
private val hysteresisHz = 40f // CwDeepDecoder.SHIFT_HYSTERESIS_HZ
/** State the decoder carries between detections. */
private data class ShiftState(val shiftHz: Float = 0f, val anchorToneHz: Float? = null)
/**
* The decision `CwDeepDecoder.runDetection` makes, and the property under test:
* silence retains the shift, and hysteresis is measured in tone space against the
* pitch that produced the active shift.
*/
private fun decide(state: ShiftState, audio: FloatArray): ShiftState {
val analysis = CwToneShifter.analyse(audio, sampleRate)
val toneHz = analysis.toneHz ?: return state
val anchor = state.anchorToneHz
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) return state
return ShiftState(analysis.shiftHz, toneHz)
}
/** Keyed CW: gated tone with noise, 60 ms on / 30 ms off, roughly 20 WPM. */
private fun keyedTone(hz: Double, samples: Int, seed: Int = 1, noise: Double = 0.02): FloatArray {
val random = Random(seed)
val period = sampleRate * 90 / 1000
return FloatArray(samples) { i ->
val gate = if (i % period < sampleRate * 60 / 1000) 1.0 else 0.0
(gate * sin(2.0 * PI * hz * i / sampleRate) +
(random.nextDouble() - 0.5) * 2 * noise).toFloat()
}
}
private fun silence(samples: Int, seed: Int = 2, noise: Double = 0.02): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * noise).toFloat() }
}
private fun steadyTone(hz: Double, samples: Int = 1280): FloatArray =
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
@Test
fun `a keying gap must not collapse an established shift`() {
// Establish a shift from a real out-of-window tone.
var state = decide(ShiftState(), steadyTone(1400.0))
val established = state.shiftHz
assertTrue("a 1400 Hz tone must produce a shift", established != 0f)
// A detection window landing in a keying gap sees no tone. Retaining the shift is
// the point: dropping it left the next ~2 s buffered unshifted, i.e. outside the
// model's range and invisible to it. Measured 11 such windows in 90 detections.
state = decide(state, silence(1280))
assertEquals(
"silence must not change the shift",
established, state.shiftHz, 0f
)
// Several gaps in a row must not erode it either.
repeat(5) { state = decide(state, silence(1280, seed = it + 3)) }
assertEquals(
"repeated silence must not erode the shift",
established, state.shiftHz, 0f
)
}
@Test
fun `silence is reported as no tone rather than a zero shift`() {
val analysis = CwToneShifter.analyse(silence(1280), sampleRate)
assertNull("noise must not be mistaken for a tone", analysis.toneHz)
assertFalse("no tone means no shift decision", analysis.needsShift)
}
@Test
fun `an estimate hopping across the window edge must not keep re-shifting`() {
// 1200.0 Hz is inside the window (shift 0); 1212.5 Hz, one scan bin away, is
// outside (a large shift). Comparing shifts made the guard lapse exactly here,
// because one side has shift 0. Measured: 10 window drops in 10 detections.
var state = decide(ShiftState(), steadyTone(1212.5))
val first = state
assertTrue("1212.5 Hz is outside the window", first.shiftHz != 0f)
state = decide(state, steadyTone(1200.0))
assertEquals(
"a one-bin hop back across the edge must not change the shift",
first.shiftHz, state.shiftHz, 0f
)
assertEquals(
"the anchor must stay put too",
first.anchorToneHz!!, state.anchorToneHz!!, 0f
)
}
@Test
fun `an edge tone with noise settles instead of thrashing`() {
// The audit measured 35 window drops in 60 detections for a 1205 Hz tone.
var state = ShiftState()
var changes = 0
repeat(30) { i ->
val next = decide(state, keyedTone(1205.0, 1280, seed = i + 10))
if (next.shiftHz != state.shiftHz) changes++
state = next
}
assertTrue(
"an edge tone must settle; the shift changed $changes times in 30 detections",
changes <= 3
)
}
@Test
fun `a real retune is still followed`() {
var state = decide(ShiftState(), steadyTone(1400.0))
val before = state.shiftHz
// 200 Hz is far beyond the hysteresis margin: the decoder must re-centre.
state = decide(state, steadyTone(1200.0 - 400.0))
assertTrue(
"a 200 Hz retune must change the shift (was $before, now ${state.shiftHz})",
state.shiftHz != before
)
}
@Test
fun `slow drift keeps the shifted tone inside the window`() {
// Hysteresis anchors on the tone that set the shift, so staleness is bounded by
// the margin rather than accumulating. Walk 1300 -> 1500 Hz in 12.5 Hz steps.
var state = decide(ShiftState(), steadyTone(1300.0))
var tone = 1300.0
var worstLanding = 0.0
while (tone <= 1500.0) {
state = decide(state, steadyTone(tone))
val landed = tone + state.shiftHz
worstLanding = maxOf(worstLanding, abs(landed - CwToneShifter.TARGET_HZ))
assertTrue(
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window",
CwToneShifter.isInsideWindow(landed.toFloat())
)
tone += 12.5
}
assertTrue(
"drift staleness must stay near the hysteresis margin, was $worstLanding Hz",
worstLanding <= hysteresisHz + 12.5
)
}
@Test
fun `shifted output stays within the range the spectrogram expects`() {
// The Hilbert kernel's L1 gain is 2.51, so mixing can exceed unity: a full-scale
// square wave measured 2.35 before clamping, and even a plain sine reached 1.05.
val shifter = CwToneShifter.Streaming()
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
assertTrue(
"a full-scale square wave must not overshoot: peak was " +
"${shiftedSquare.maxOf { abs(it) }}",
shiftedSquare.all { abs(it) <= 1f }
)
shifter.reset()
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
assertTrue(
"a full-scale sine must not overshoot: peak was ${shiftedSine.maxOf { abs(it) }}",
shiftedSine.all { abs(it) <= 1f }
)
// Clamping must not flatten the signal: the tone still has to be there.
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
assertEquals(
"clamping must preserve the shifted tone",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
)
}
@Test
fun `stateless shift also stays in range`() {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shifted = CwToneShifter.shift(square, -700f, sampleRate)
assertTrue(
"peak was ${shifted.maxOf { abs(it) }}",
shifted.all { abs(it) <= 1f }
)
}
@Test
fun `detector tolerates pathological input`() {
// From the audit: DC offsets, clipping and short buffers must not produce a
// bogus shift, since a wrong shift moves a perfectly good tone out of range.
for (offset in listOf(0.5, 1.0, 5.0, 50.0)) {
val biased = FloatArray(1280) { i ->
(offset + sin(2.0 * PI * 800.0 * i / sampleRate)).toFloat()
}
val detected = CwToneShifter.detectToneHz(biased, sampleRate)
assertEquals(
"a DC offset of $offset must not hide the tone",
800.0, detected!!.toDouble(), 25.0
)
}
val zeros = FloatArray(1280)
assertNull("all zeros must not report a tone", CwToneShifter.detectToneHz(zeros, sampleRate))
for (size in listOf(0, 1, 2, 63)) {
assertNull(
"a $size-sample buffer is too short to detect from",
CwToneShifter.detectToneHz(FloatArray(size), sampleRate)
)
}
val withNan = FloatArray(1280) { i ->
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
}
assertNull(
"a NaN sample must yield no tone rather than a garbage shift",
CwToneShifter.detectToneHz(withNan, sampleRate)
)
}
}
@@ -0,0 +1,167 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Signal-level properties of the shifter: the range the spectrogram expects, the
* detector's threshold trade-off, and behaviour on inputs a phone mic can really produce.
*
* The decision rule that consumes these estimates is covered by [CwShiftDeciderTest].
*/
class CwToneShiftSignalTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
/** Keyed CW: gated tone with noise, 60 ms on / 30 ms off, roughly 20 WPM. */
private fun keyedTone(hz: Double, samples: Int = 1280, seed: Int = 1, noise: Double = 0.02): FloatArray {
val random = Random(seed)
val period = sampleRate * 90 / 1000
return FloatArray(samples) { i ->
val gate = if (i % period < sampleRate * 60 / 1000) 1.0 else 0.0
(gate * sin(2.0 * PI * hz * i / sampleRate) +
(random.nextDouble() - 0.5) * 2 * noise).toFloat()
}
}
private fun noiseOnly(samples: Int = 1280, seed: Int = 2, level: Double = 1.0): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
/**
* The prominence threshold sits between two measured populations and both sides
* matter. Too low and noise is mistaken for a tone, which moves a good signal out of
* the model's range; too high and copyable weak signals are never shifted, which is
* the very failure the feature exists to prevent.
*/
@Test
fun `prominence threshold rejects noise without rejecting weak signals`() {
var falsePositives = 0
repeat(20) { seed ->
if (CwToneShifter.detectToneHz(noiseOnly(seed = seed + 500), sampleRate) != null) {
falsePositives++
}
}
assertEquals("noise must never be reported as a tone", 0, falsePositives)
// Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible,
// decodable, and the region an over-tight threshold silently discards.
for (hz in listOf(300.0, 800.0, 1400.0)) {
val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate)
assertEquals(
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise",
hz, detected!!.toDouble(), 25.0
)
}
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " +
"ceiling of ~3.4",
CwToneShifter.MIN_PROMINENCE > 3.4
)
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " +
"keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB",
CwToneShifter.MIN_PROMINENCE < 5.2
)
}
/**
* The Hilbert kernel's L1 gain is 2.51, so summing the in-phase and quadrature paths
* overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The
* spectrogram takes log1p of the magnitude, so an overshoot is not fatal, but it
* moves the level away from what the model was trained on.
*/
@Test
fun `shifted output stays within the range the spectrogram expects`() {
val shifter = CwToneShifter.Streaming()
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
assertTrue(
"a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}",
shiftedSquare.all { abs(it) <= 1f }
)
shifter.reset()
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
assertTrue(
"a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}",
shiftedSine.all { abs(it) <= 1f }
)
// Limiting must not flatten the signal away: the tone still has to be there.
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
assertEquals(
"limiting must preserve the shifted tone",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
)
}
@Test
fun `stateless shift also stays in range`() {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shifted = CwToneShifter.shift(square, -700f, sampleRate)
assertTrue(
"peak was ${shifted.maxOf { abs(it) }}",
shifted.all { abs(it) <= 1f }
)
}
@Test
fun `detector tolerates pathological input`() {
// A wrong shift moves a perfectly good tone out of range, so a bogus estimate is
// worse than none: these inputs must produce the right tone or nothing at all.
for (offset in listOf(0.5, 1.0, 5.0, 50.0)) {
val biased = FloatArray(1280) { i ->
(offset + sin(2.0 * PI * 800.0 * i / sampleRate)).toFloat()
}
val detected = CwToneShifter.detectToneHz(biased, sampleRate)
assertEquals(
"a DC offset of $offset must not hide the tone",
800.0, detected!!.toDouble(), 25.0
)
}
assertNull(
"all zeros must not report a tone",
CwToneShifter.detectToneHz(FloatArray(1280), sampleRate)
)
for (size in listOf(0, 1, 2, 63)) {
assertNull(
"a $size-sample buffer is too short to detect from",
CwToneShifter.detectToneHz(FloatArray(size), sampleRate)
)
}
val withNan = FloatArray(1280) { i ->
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
}
assertNull(
"a NaN sample must yield no tone rather than a garbage shift",
CwToneShifter.detectToneHz(withNan, sampleRate)
)
// Clipping must not let a harmonic outrank the fundamental.
for (drive in listOf(1.0, 4.0, 20.0, 200.0)) {
val clipped = FloatArray(1280) { i ->
(drive * sin(2.0 * PI * 500.0 * i / sampleRate)).coerceIn(-1.0, 1.0).toFloat()
}
val detected = CwToneShifter.detectToneHz(clipped, sampleRate)
assertEquals(
"at ${drive}x drive the fundamental must still win",
500.0, detected!!.toDouble(), 25.0
)
}
}
}