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 a2e323f9..4f8e7535 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 @@ -145,6 +145,7 @@ class CwDeepDecoder( */ private val detectBuffer = FloatArray(DETECT_MIN_SAMPLES) private var detectFill = 0 + private var detectWriteIndex = 0 /** Carries Hilbert filter history and mixer phase across capture chunks. */ private val streamingShifter = CwToneShifter.Streaming() @@ -328,6 +329,7 @@ class CwDeepDecoder( detectedToneHz = null lastDetectAtMs = 0L detectFill = 0 + detectWriteIndex = 0 streamingShifter.reset() } @@ -339,9 +341,7 @@ class CwDeepDecoder( val elapsed = now - lastDetectAtMs if (detectFill >= DETECT_MIN_SAMPLES && elapsed >= DETECT_INTERVAL_MS) { lastDetectAtMs = now - val sample = detectBuffer.copyOf(detectFill) - detectFill = 0 - runDetection(sample) + runDetection(drainDetectionBuffer()) } // Streaming keeps the Hilbert filter history and mixer phase across chunks; @@ -349,21 +349,39 @@ class CwDeepDecoder( return streamingShifter.process(resampled, activeShiftHz, CwDeepSpectrogram.SAMPLE_RATE) } - /** Collect resampled chunks until [DETECT_MIN_SAMPLES] is available. */ + /** + * 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 larger than the detection buffer only needs to contribute its tail. + // 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) { - if (detectFill == detectBuffer.size) { - // Slide the window so detection always sees the most recent audio. - detectBuffer.copyInto(detectBuffer, 0, 1, detectFill) - detectFill-- - } - detectBuffer[detectFill++] = chunk[i] + 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. * @@ -530,6 +548,7 @@ class CwDeepDecoder( detectedToneHz = null lastDetectAtMs = 0L detectFill = 0 + detectWriteIndex = 0 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 3de4d37c..818e2bfa 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 @@ -161,4 +161,69 @@ class CwToneShiftGateTest { abs(shiftAt1300 - shiftAt1400) >= hysteresisHz ) } + + /** + * 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) + } }