fix(cw): stop silence and edge estimates from defeating the tone shift
Two audit findings, both measured, both able to silently disable the feature. A detection window landing in a keying gap used to collapse an established shift to zero. CW is keyed, so gaps are normal: over 180 s of keyed audio at 1400 Hz, 11 of 90 detections saw no tone, and each one wiped the decode window and left the next ~2 s buffered unshifted - outside the model's range and therefore invisible to it. Absence of a tone is now absence of evidence and the active shift is retained. Hysteresis moved from shift space to tone space, anchored on the pitch that produced the active shift. The old rule required a non-zero previous shift and a needed shift, so it lapsed exactly where the jump is largest: at the 1200 Hz edge one 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large shift). Measured 35 window drops in 60 detections for a 1205 Hz tone, and 10 in 10 for a bare one-bin hop. A shift of zero is a real state, not the absence of one. Slow drift still catches up, since the anchor bounds staleness at the margin rather than letting it accumulate. Detection prominence raised from 3.0 to 8.0. Pure noise peaks at 2.0-3.3 times its own spectral mean, so 3.0 admitted roughly one noise window in five as a "tone" - and a false tone is worse than none, since it moves a good signal out of range. Keyed CW measures 47-51, so the gap is wide. Shifted output is clamped to the +/-1.0 range the spectrogram assumes. The Hilbert kernel's L1 gain is 2.51, so mixing overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The detection pool moved to core:domain as CwDetectionPool so its ring behaviour can be tested directly - mutation testing showed the previous private implementation was unreachable from any test. Its chronological-order contract now has 11 tests driving the real class. Removed the write-only detectedToneHz field. 74 CW tests pass, golden vectors included.
This commit is contained in:
1 parent
f6db55b35c
commit
fdb44af9ff
7 files changed
+603
-147
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+182
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user