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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-238
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+167
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user