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 4f8e7535..0eed225d 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 @@ -25,6 +25,7 @@ import android.util.Log 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.CwToneShifter import com.rtbishop.look4sat.core.domain.cw.ICwDecoder import kotlinx.coroutines.CancellationException @@ -79,7 +80,7 @@ class CwDeepDecoder( * A capture chunk is ~100 ms, which is 4410 samples at the 44.1 kHz capture * rate but only 320 after resampling to 3200 Hz. Gating on a single chunk * reaching this size would therefore never fire, so chunks are accumulated in - * [detectBuffer] until enough audio is available. + * [detectionPool] until enough audio is available. */ const val DETECT_MIN_SAMPLES = 1280 @@ -132,20 +133,21 @@ class CwDeepDecoder( /** Shift currently applied to incoming audio; 0 when the tone needs no move. */ private var activeShiftHz = 0f - /** Last detected tone, for logging and for the pitch readout while shifting. */ - private var detectedToneHz: Float? = null + /** + * 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 /** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */ private var lastDetectAtMs = 0L /** - * Accumulates resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single - * capture chunk is only 320 samples once resampled, so detection has to pool - * several of them. + * Pools resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single capture + * chunk is only 320 samples once resampled, so detection has to pool several. */ - private val detectBuffer = FloatArray(DETECT_MIN_SAMPLES) - private var detectFill = 0 - private var detectWriteIndex = 0 + private val detectionPool = CwDetectionPool(DETECT_MIN_SAMPLES) /** Carries Hilbert filter history and mixer phase across capture chunks. */ private val streamingShifter = CwToneShifter.Streaming() @@ -326,22 +328,21 @@ class CwDeepDecoder( Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio") dropBufferedAudio() activeShiftHz = 0f - detectedToneHz = null + shiftAnchorToneHz = null lastDetectAtMs = 0L - detectFill = 0 - detectWriteIndex = 0 + detectionPool.clear() streamingShifter.reset() } if (!enabled) return resampled - accumulateForDetection(resampled) + detectionPool.add(resampled) val now = System.currentTimeMillis() val elapsed = now - lastDetectAtMs - if (detectFill >= DETECT_MIN_SAMPLES && elapsed >= DETECT_INTERVAL_MS) { + if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) { lastDetectAtMs = now - runDetection(drainDetectionBuffer()) + runDetection(detectionPool.drain()) } // Streaming keeps the Hilbert filter history and mixer phase across chunks; @@ -349,39 +350,6 @@ class CwDeepDecoder( return streamingShifter.process(resampled, activeShiftHz, CwDeepSpectrogram.SAMPLE_RATE) } - /** - * Collect resampled chunks until [DETECT_MIN_SAMPLES] is available. - * - * A ring buffer rather than a sliding array: detection is throttled to - * [DETECT_INTERVAL_MS] but the buffer fills in 400 ms, so for the remaining 1.6 s - * every chunk arrives at a full buffer. Shifting the array down one slot per sample - * cost 320 copies of 1280 floats per chunk - measured at 24320 whole-array moves per - * 10 s of audio, on the capture thread. Overwriting the oldest slot is O(1). - */ - private fun accumulateForDetection(chunk: FloatArray) { - if (chunk.isEmpty()) return - // A chunk longer than the buffer can only contribute its tail. - val start = maxOf(0, chunk.size - detectBuffer.size) - for (i in start until chunk.size) { - detectBuffer[detectWriteIndex] = chunk[i] - detectWriteIndex = (detectWriteIndex + 1) % detectBuffer.size - if (detectFill < detectBuffer.size) detectFill++ - } - } - - /** Copy the buffered audio out in chronological order, oldest sample first. */ - private fun drainDetectionBuffer(): FloatArray { - val out = FloatArray(detectFill) - // When full, the oldest sample sits at the write cursor; otherwise at index 0. - val oldest = if (detectFill == detectBuffer.size) detectWriteIndex else 0 - for (i in 0 until detectFill) { - out[i] = detectBuffer[(oldest + i) % detectBuffer.size] - } - detectFill = 0 - detectWriteIndex = 0 - return out - } - /** * Discard buffered audio that was shifted by a now-stale amount. * @@ -399,30 +367,52 @@ class CwDeepDecoder( val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE) val previousShift = activeShiftHz - // Ignore small changes. Dropping the window costs 20 s of context, so a tone - // wandering by a few Hz - or a detector estimate landing on an adjacent 12.5 Hz - // scan bin - must not keep wiping it. Re-shifting only pays off once the tone - // has moved enough to matter against the 800 Hz window centre. - val keepPreviousShift = previousShift != 0f && - analysis.needsShift && - abs(analysis.shiftHz - previousShift) < SHIFT_HYSTERESIS_HZ - - detectedToneHz = analysis.toneHz - if (!keepPreviousShift) activeShiftHz = analysis.shiftHz - - when { - analysis.toneHz == null -> - Log.d(TAG, "toneShift: no tone in ${sample.size} samples, shift stays 0") - - !analysis.needsShift -> Log.d( + // 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( TAG, - "toneShift: tone=${analysis.toneHz}Hz inside " + + "toneShift: no tone in ${sample.size} samples, keeping shift=${previousShift}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( + TAG, + "toneShift: tone=${toneHz}Hz within ${SHIFT_HYSTERESIS_HZ}Hz of " + + "${anchorTone}Hz, keeping shift=${previousShift}Hz" + ) + return + } + + activeShiftHz = analysis.shiftHz + shiftAnchorToneHz = toneHz + + if (!analysis.needsShift) { + Log.d( + TAG, + "toneShift: tone=${toneHz}Hz inside " + "${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift" ) - - else -> Log.i( + } else { + Log.i( TAG, - "toneShift: tone=${analysis.toneHz}Hz outside window, " + + "toneShift: tone=${toneHz}Hz outside window, " + "shifting ${analysis.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz" ) } @@ -545,10 +535,9 @@ class CwDeepDecoder( _lastInferenceMs.value = 0 // Re-detect from scratch: the operator may have retuned before resetting. activeShiftHz = 0f - detectedToneHz = null + shiftAnchorToneHz = null lastDetectAtMs = 0L - detectFill = 0 - detectWriteIndex = 0 + detectionPool.clear() streamingShifter.reset() // Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the // current setting instead of reporting a spurious change. 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 818e2bfa..977d9c1e 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 @@ -162,68 +162,4 @@ class CwToneShiftGateTest { ) } - /** - * The detection pool must hand the analyser the most recent audio in chronological - * order. The decoder implements this as a ring buffer because detection is throttled - * to 2 s while the pool fills in 400 ms, so most chunks arrive at a full buffer. - * - * This pins the contract with the same ring semantics the decoder uses: getting the - * order wrong would feed the detector a spliced waveform and corrupt every estimate, - * silently, which no other assertion here would notice. - */ - @Test - fun `detection pool yields the most recent samples in order`() { - val capacity = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES - val pool = FloatArray(capacity) - var fill = 0 - var writeIndex = 0 - - fun accumulate(chunk: FloatArray) { - val start = maxOf(0, chunk.size - pool.size) - for (i in start until chunk.size) { - pool[writeIndex] = chunk[i] - writeIndex = (writeIndex + 1) % pool.size - if (fill < pool.size) fill++ - } - } - - fun drain(): FloatArray { - val out = FloatArray(fill) - val oldest = if (fill == pool.size) writeIndex else 0 - for (i in 0 until fill) out[i] = pool[(oldest + i) % pool.size] - fill = 0 - writeIndex = 0 - return out - } - - // Feed a monotonically increasing ramp in 320-sample chunks, past capacity. - var next = 0f - repeat(10) { - accumulate(FloatArray(320) { next++ }) - } - val drained = drain() - - assertEquals("a full pool must hand over exactly its capacity", capacity, drained.size) - assertEquals( - "the pool must end on the newest sample fed", - next - 1f, drained.last(), 0f - ) - assertEquals( - "the pool must start capacity-1 samples before the newest", - next - capacity, drained.first(), 0f - ) - for (i in 1 until drained.size) { - assertEquals( - "sample $i is out of order, so the ring wrap is wrong", - drained[i - 1] + 1f, drained[i], 0f - ) - } - - // A short run must come back whole, without stale slots from the previous pass. - accumulate(FloatArray(320) { 9000f + it }) - val partial = drain() - assertEquals("a partial pool must not report capacity", 320, partial.size) - assertEquals(9000f, partial.first(), 0f) - assertEquals(9319f, partial.last(), 0f) - } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt new file mode 100644 index 00000000..95bdffa1 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt @@ -0,0 +1,88 @@ +/* + * 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 + +/** + * Pools capture chunks until enough audio is available for tone detection. + * + * [CwToneShifter.detectToneHz] scans bin by bin, so it needs a few hundred + * milliseconds to resolve a pitch. A capture chunk is only 320 samples once + * resampled to [CwDeepSpectrogram.SAMPLE_RATE], hence the pooling: without it a + * per-chunk size check can never be satisfied and detection silently never runs. + * + * A ring buffer rather than a sliding array. Detection is throttled to a couple of + * seconds while the pool fills in a few hundred milliseconds, so most chunks arrive + * at a full buffer; shifting the array down one slot per sample cost 320 copies of + * 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio on + * the capture thread. Writing to a ring index is O(1). + * + * Not thread-safe: the decoder drives it from a single capture coroutine. + * + * @param capacity samples retained; also the size [drain] returns once full. + */ +class CwDetectionPool(val capacity: Int) { + + init { + require(capacity > 0) { "capacity must be positive, was $capacity" } + } + + private val samples = FloatArray(capacity) + private var writeIndex = 0 + + /** Samples currently pooled, never above [capacity]. */ + var size: Int = 0 + private set + + /** True once [capacity] samples are pooled and detection can run. */ + val isReady: Boolean get() = size >= capacity + + /** Add a chunk, overwriting the oldest samples once full. */ + fun add(chunk: FloatArray) { + if (chunk.isEmpty()) return + // A chunk longer than the pool can only contribute its tail. + val start = maxOf(0, chunk.size - capacity) + for (i in start until chunk.size) { + samples[writeIndex] = chunk[i] + writeIndex = (writeIndex + 1) % capacity + if (size < capacity) size++ + } + } + + /** + * Hand over the pooled audio in chronological order and empty the pool. + * + * Oldest sample first: the detector measures a waveform, so returning the ring in + * storage order would splice it at the wrap point and corrupt every estimate. + */ + fun drain(): FloatArray { + val out = FloatArray(size) + // Once full the oldest sample sits at the write cursor; before that at index 0. + val oldest = if (size == capacity) writeIndex else 0 + for (i in 0 until size) { + out[i] = samples[(oldest + i) % capacity] + } + clear() + return out + } + + /** Discard everything pooled so far. */ + fun clear() { + size = 0 + writeIndex = 0 + } +} 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 28cefb31..2844d841 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 @@ -60,10 +60,15 @@ object CwToneShifter { const val MIN_DETECTABLE_HZ = 100.0 /** - * A detected peak must exceed the spectrum mean by this factor to count as a - * tone. Below it the input is noise and shifting would just centre the noise. + * 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. */ - const val MIN_PROMINENCE = 3.0 + const val MIN_PROMINENCE = 8.0 /** Hilbert transformer length. Odd so the group delay is a whole sample. */ private const val HILBERT_TAPS = 63 @@ -191,7 +196,7 @@ object CwToneShifter { val step = 2.0 * PI * shiftHz / sampleRate for (i in audio.indices) { val phase = step * i - out[i] = (audio[i] * cos(phase) - quadrature[i] * sin(phase)).toFloat() + out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase)) } return out } @@ -249,8 +254,8 @@ object CwToneShifter { if (j >= 0 && j < combined.size) quadrature += hilbertKernel[k] * combined[j] } val currentPhase = phase + step * i - out[i] = (combined[centre] * cos(currentPhase) - - quadrature * sin(currentPhase)).toFloat() + val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase) + out[i] = clampToUnit(mixed) } // Keep the phase bounded; letting it grow loses float precision. @@ -291,6 +296,20 @@ object CwToneShifter { return shift(audio, analysis.shiftHz, sampleRate) to analysis } + /** + * Keep a mixed sample inside the +/-1.0 range the spectrogram assumes. + * + * The Hilbert kernel has an L1 gain of 2.51, so summing the in-phase and quadrature + * paths can exceed unity even for a full-scale sine (measured 1.05 at 1500 Hz, 2.35 + * for a square wave). The spectrogram takes log1p of the magnitude, so an overshoot + * is not fatal, but it shifts the level the model was trained on. + */ + private fun clampToUnit(value: Double): Float = when { + value > 1.0 -> 1f + value < -1.0 -> -1f + else -> value.toFloat() + } + /** True when [toneHz] lies inside the model's analysis window. */ fun isInsideWindow(toneHz: Float): Boolean = toneHz >= CwDeepSpectrogram.MIN_FREQ_HZ && toneHz <= CwDeepSpectrogram.MAX_FREQ_HZ diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt new file mode 100644 index 00000000..bea4df8e --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt @@ -0,0 +1,182 @@ +package com.rtbishop.look4sat.core.domain.cw + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the + * samples it hands over must be the most recent audio in chronological order. Getting + * the ring wrap wrong would splice the waveform and corrupt every pitch estimate + * silently - no downstream assertion would notice, which is why these tests drive the + * real class rather than restating its logic. + */ +class CwDetectionPoolTest { + + private val capacity = 1280 + + /** Chunk of a monotonic ramp, so any reordering is visible. */ + private fun ramp(from: Int, count: Int) = FloatArray(count) { (from + it).toFloat() } + + private fun assertAscending(values: FloatArray) { + for (i in 1 until values.size) { + assertEquals( + "sample $i breaks the ramp, so the ring wrap is wrong", + values[i - 1] + 1f, values[i], 0f + ) + } + } + + @Test + fun `reports readiness only once capacity is reached`() { + val pool = CwDetectionPool(capacity) + assertFalse("an empty pool is not ready", pool.isReady) + assertEquals(0, pool.size) + + // Three 320-sample chunks are 960 samples: still short. + repeat(3) { pool.add(ramp(it * 320, 320)) } + assertEquals(960, pool.size) + assertFalse("960 of $capacity samples is not ready", pool.isReady) + + pool.add(ramp(960, 320)) + assertEquals(capacity, pool.size) + assertTrue("a full pool must report ready", pool.isReady) + } + + @Test + fun `drains a partial fill without stale slots`() { + val pool = CwDetectionPool(capacity) + pool.add(ramp(500, 320)) + + val drained = pool.drain() + assertEquals("only what was added may come back", 320, drained.size) + assertEquals(500f, drained.first(), 0f) + assertEquals(819f, drained.last(), 0f) + assertAscending(drained) + assertEquals("draining empties the pool", 0, pool.size) + } + + @Test + fun `drains exactly the most recent samples once wrapped`() { + val pool = CwDetectionPool(capacity) + // 10 chunks of 320 = 3200 samples through a 1280-sample pool. + repeat(10) { pool.add(ramp(it * 320, 320)) } + + val drained = pool.drain() + assertEquals(capacity, drained.size) + assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f) + assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f) + assertAscending(drained) + } + + @Test + fun `keeps only the tail of an oversized chunk`() { + val pool = CwDetectionPool(capacity) + pool.add(ramp(0, 5000)) + + val drained = pool.drain() + assertEquals(capacity, drained.size) + assertEquals(4999f, drained.last(), 0f) + assertEquals((5000 - capacity).toFloat(), drained.first(), 0f) + assertAscending(drained) + } + + @Test + fun `handles single-sample chunks`() { + val pool = CwDetectionPool(capacity) + // Far more single-sample adds than the capacity, exercising every wrap position. + repeat(2000) { pool.add(floatArrayOf(it.toFloat())) } + + val drained = pool.drain() + assertEquals(capacity, drained.size) + assertEquals(1999f, drained.last(), 0f) + assertEquals((2000 - capacity).toFloat(), drained.first(), 0f) + assertAscending(drained) + } + + @Test + fun `is reusable after draining`() { + val pool = CwDetectionPool(capacity) + repeat(5) { pool.add(ramp(it * 320, 320)) } + pool.drain() + + // A second pass must not inherit anything from the first. + pool.add(ramp(9000, 320)) + val drained = pool.drain() + assertEquals(320, drained.size) + assertEquals(9000f, drained.first(), 0f) + assertEquals(9319f, drained.last(), 0f) + assertAscending(drained) + } + + @Test + fun `clear discards pooled audio`() { + val pool = CwDetectionPool(capacity) + pool.add(ramp(0, 640)) + pool.clear() + + assertEquals(0, pool.size) + assertFalse(pool.isReady) + pool.add(ramp(7000, 320)) + val drained = pool.drain() + assertEquals("cleared samples must not reappear", 320, drained.size) + assertEquals(7000f, drained.first(), 0f) + } + + @Test + fun `empty chunks are ignored`() { + val pool = CwDetectionPool(capacity) + pool.add(ramp(0, 320)) + pool.add(FloatArray(0)) + assertEquals("an empty chunk must not change the pool", 320, pool.size) + assertAscending(pool.drain()) + } + + @Test + fun `chunk exactly the size of the pool is kept whole`() { + val pool = CwDetectionPool(capacity) + pool.add(ramp(100, capacity)) + + val drained = pool.drain() + assertEquals(capacity, drained.size) + assertEquals(100f, drained.first(), 0f) + assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f) + assertAscending(drained) + } + + @Test + fun `pooled audio is long enough for the detector to resolve a pitch`() { + // The pool exists to make detection possible at all; prove the pooled length + // actually works rather than only that the plumbing moves samples around. + val sampleRate = CwDeepSpectrogram.SAMPLE_RATE + val pool = CwDetectionPool(capacity) + var phase = 0 + repeat(4) { + pool.add(FloatArray(320) { i -> + kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat() + }) + phase += 320 + } + assertTrue(pool.isReady) + + val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate) + assertEquals( + "four pooled capture chunks must be enough to detect a 1500 Hz tone", + 1500.0, detected!!.toDouble(), 25.0 + ) + } + + @Test + fun `rejects a non-positive capacity`() { + for (bad in listOf(0, -1, -1280)) { + try { + CwDetectionPool(bad) + throw AssertionError("capacity $bad should have been rejected") + } catch (expected: IllegalArgumentException) { + // The decoder derives capacity from a constant; a zero would otherwise + // fail later as a division by zero in the ring arithmetic. + } + } + } +} 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 new file mode 100644 index 00000000..b00a483b --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftDecisionTest.kt @@ -0,0 +1,238 @@ +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/CwToneShifterStreamingTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt index b7a7e183..3df5b48c 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt @@ -61,22 +61,26 @@ class CwToneShifterStreamingTest { } @Test - fun `chunked streaming keeps a steady tone as flat as whole-buffer shifting`() { + fun `chunked streaming keeps a steady tone flat`() { val audio = continuousTone(1500.0, chunkSize * 20) val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat() - val whole = CwToneShifter.shift(audio, shiftHz, sampleRate) val streamed = processInChunks(audio, shiftHz) // Skip the filter's start-up transient: with no history the first taps are cold. val skip = 128 - val wholeRipple = ripple(whole, skip) val streamedRipple = ripple(streamed, skip) + // Absolute, not relative to the whole-buffer figure: clamping pins a full-scale + // tone at exactly 1.0, so the whole-buffer ripple collapses to ~0.001% and any + // ratio against it explodes. What matters is the absolute number - a 20 WPM dot + // spans 192 samples, so sub-2% envelope ripple cannot move a keying decision. + // Measured 0.79% with state carried across chunks; dropping the filter history + // takes it to several percent, and dropping the phase far higher. assertTrue( - "streaming envelope ripple ${streamedRipple}% must stay close to the " + - "whole-buffer baseline ${wholeRipple}% (chunk-edge state lost?)", - streamedRipple < wholeRipple * 3.0 + 0.5 + "streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " + + "filter state or mixer phase is not being carried", + streamedRipple < 2.0 ) }