diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt index 0eed225d..6d76ed17 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt @@ -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() diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt index 977d9c1e..d576c505 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt @@ -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 - ) - } - } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt new file mode 100644 index 00000000..3cf630ac --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt @@ -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 . + */ +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 + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt index 2844d841..a812562f 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt @@ -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 diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt new file mode 100644 index 00000000..a3807fc1 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt @@ -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) + } + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftDecisionTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftDecisionTest.kt deleted file mode 100644 index b00a483b..00000000 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftDecisionTest.kt +++ /dev/null @@ -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) - ) - } -} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt new file mode 100644 index 00000000..338fdf41 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt @@ -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 + ) + } + } +}