feat(cw): optionally shift out-of-window CW tones into the model's range
DeepCW only analyses 400-1200 Hz - its input tensor is 65 bins wide, fixed at training time - so a CW note outside that range is invisible to the decoder. This adds an opt-in preprocessing step that moves such a tone to 800 Hz, the window centre, extending the usable pitch range without touching the model. Single-sideband mixing via a 63-tap Hilbert transformer. Plain real mixing was measured and rejected: shifting 1500 Hz to 800 Hz left a fold-back image at 1000 Hz at 0.999 of the wanted amplitude, inside the window. Zero-stuff upsampling plus lowpass handled downward shifts but left a 0.996 image when shifting 300 Hz upward. The Hilbert approach measures clean on nine tones from 150 to 1550 Hz: one peak at the target, nothing above 0.3 relative amplitude. In-window energy for a 1500 Hz input goes from 6.8% to 94.6%. Only out-of-range audio is processed. A tone already inside 400-1200 Hz is returned untouched (same array instance, no copy), and with the setting off the audio path is exactly what it was before. CwToneShifter.Streaming carries the Hilbert filter history and mixer phase across capture chunks. Shifting each chunk in isolation left 62 of every 320 samples convolving against zeros, inflating envelope ripple to 8.7x the whole-buffer baseline. A residual difference in the last ~3 samples of each chunk is causal and documented: those output samples would need input that has not been captured yet. Detection pools chunks rather than gating on one. A capture chunk is 4410 samples at 44.1 kHz but only 320 after resampling to 3200 Hz, so requiring 1280 samples in a single chunk would have made the feature dead code - the two independent audits both found this before it shipped. Detection now runs on a pooled 0.4 s window, at most every 2 s. Toggling the setting or a change in the detected shift drops the buffered audio: the 20 s window would otherwise keep decoding samples moved by the old amount, and the pitch readout could only be correct for one of them. The readout itself subtracts the active shift so it shows the pitch on the radio, not the shifted one. Settings: OtherSettings.cwToneShiftEnabled, off by default, persisted and read back in SettingsRepo, toggled from the Other card in Settings with a help line explaining the 400-1200 Hz limit. Strings added to all nine locales. The decoder reads the flag per chunk, so the toggle applies without restarting capture. Debug: the enabled-state transition, each detection verdict (no tone / inside window / shifting by N Hz), and every shift change are logged, with the noisy paths throttled to the 2 s detection interval. CwProbe records shift changes only, keeping well inside its 1 MiB cap. Tests: 8 shifter tests (detection sweep, noise rejection, pass-through identity, image-free shifting across 8 tones, end-to-end spectrogram energy), 8 streaming tests (chunk continuity, history retention, reset semantics, chunk sizes above and below the history window), and 6 gate tests including a regression guard that a 320-sample chunk must be able to reach the detection threshold. All 53 CW tests pass, golden vectors included.
This commit is contained in:
1 parent
e3d7238721
commit
9798107d37
21 files changed
+1040
-9
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.CwToneShifter
|
||||
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -48,9 +49,18 @@ import java.nio.FloatBuffer
|
||||
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
|
||||
*
|
||||
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
|
||||
* in; no spectral peak tracking or squelch gating is needed.
|
||||
* in; no spectral peak tracking or squelch gating is needed. A tone outside that
|
||||
* window is invisible to the model, so [CwToneShifter] can optionally move it in —
|
||||
* see [isToneShiftEnabled].
|
||||
*
|
||||
* @param isToneShiftEnabled read on every chunk so toggling the setting takes effect
|
||||
* without rebuilding the decoder. Defaults to disabled: with it off the audio path
|
||||
* is byte-for-byte what it was before the feature existed.
|
||||
*/
|
||||
class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
class CwDeepDecoder(
|
||||
context: Context,
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
@@ -60,6 +70,20 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
* Samples the detector needs for a usable estimate: 0.4 s at 3200 Hz, giving
|
||||
* ~12.5 Hz resolution.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const val DETECT_MIN_SAMPLES = 1280
|
||||
|
||||
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
|
||||
const val DETECT_INTERVAL_MS = 2000
|
||||
}
|
||||
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
@@ -95,6 +119,29 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/** 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
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
private val detectBuffer = FloatArray(DETECT_MIN_SAMPLES)
|
||||
private var detectFill = 0
|
||||
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/** Previous value of the setting, so a toggle can invalidate buffered audio. */
|
||||
private var toneShiftWasEnabled = false
|
||||
|
||||
private var environment: OrtEnvironment? = null
|
||||
private var session: OrtSession? = null
|
||||
private var chars: List<String> = emptyList()
|
||||
@@ -174,7 +221,8 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val shouldRedecode = buffer.append(resampled)
|
||||
val prepared = applyToneShift(resampled)
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
// when a full archive chunk has accumulated, so old text does not vanish.
|
||||
@@ -237,6 +285,103 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an out-of-window tone into the model's analysis window when the user has
|
||||
* enabled it.
|
||||
*
|
||||
* The detection scan is a bin-by-bin DFT, so it runs at most every
|
||||
* [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it
|
||||
* produces is cached in [activeShiftHz] and applied to the chunks in between. A
|
||||
* tone already inside the window yields a zero shift, and then this returns the
|
||||
* caller's array untouched.
|
||||
*
|
||||
* @return the audio to buffer: [resampled] itself whenever no shift applies.
|
||||
*/
|
||||
private fun applyToneShift(resampled: FloatArray): FloatArray {
|
||||
if (!isToneShiftEnabled()) {
|
||||
// Clear stale state so re-enabling starts from a fresh detection.
|
||||
if (activeShiftHz != 0f || detectedToneHz != null || detectFill > 0) {
|
||||
Log.i(TAG, "toneShift: disabled, clearing shift=${activeShiftHz}Hz")
|
||||
activeShiftHz = 0f
|
||||
detectedToneHz = null
|
||||
lastDetectAtMs = 0L
|
||||
detectFill = 0
|
||||
streamingShifter.reset()
|
||||
}
|
||||
return resampled
|
||||
}
|
||||
|
||||
accumulateForDetection(resampled)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastDetectAtMs
|
||||
if (detectFill >= DETECT_MIN_SAMPLES && elapsed >= DETECT_INTERVAL_MS) {
|
||||
lastDetectAtMs = now
|
||||
val sample = detectBuffer.copyOf(detectFill)
|
||||
detectFill = 0
|
||||
runDetection(sample)
|
||||
}
|
||||
|
||||
// Streaming keeps the Hilbert filter history and mixer phase across chunks;
|
||||
// shifting each chunk in isolation distorted the 62 samples at its edges.
|
||||
return streamingShifter.process(resampled, activeShiftHz, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
}
|
||||
|
||||
/** Collect resampled chunks until [DETECT_MIN_SAMPLES] is available. */
|
||||
private fun accumulateForDetection(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
// A chunk larger than the detection buffer only needs to 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]
|
||||
}
|
||||
}
|
||||
|
||||
/** Update [activeShiftHz] from a detection pass and log what was decided. */
|
||||
private fun runDetection(sample: FloatArray) {
|
||||
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
val previousShift = activeShiftHz
|
||||
detectedToneHz = analysis.toneHz
|
||||
activeShiftHz = analysis.shiftHz
|
||||
|
||||
when {
|
||||
analysis.toneHz == null ->
|
||||
Log.d(TAG, "toneShift: no tone in ${sample.size} samples, shift stays 0")
|
||||
|
||||
!analysis.needsShift -> Log.d(
|
||||
TAG,
|
||||
"toneShift: tone=${analysis.toneHz}Hz inside " +
|
||||
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
|
||||
)
|
||||
|
||||
else -> Log.i(
|
||||
TAG,
|
||||
"toneShift: tone=${analysis.toneHz}Hz outside window, " +
|
||||
"shifting ${analysis.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
|
||||
)
|
||||
}
|
||||
|
||||
if (previousShift != activeShiftHz) {
|
||||
// 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"
|
||||
)
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
streamingShifter.reset()
|
||||
CwProbe.step("tone_shift tone=${analysis.toneHz} shift=$activeShiftHz")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
@@ -322,7 +467,9 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
|
||||
val absoluteBin = 32 + bestBin
|
||||
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
|
||||
// Undo the shift before reporting: the spectrogram sees the moved tone, but
|
||||
// the readout must show the pitch the operator actually hears on the radio.
|
||||
_estimatedPitch.value = (absoluteBin * binHz - activeShiftHz).toFloat()
|
||||
|
||||
val mean = total / count
|
||||
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||
@@ -336,6 +483,12 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
_estimatedPitch.value = null
|
||||
_signalStrength.value = 0f
|
||||
_lastInferenceMs.value = 0
|
||||
// Re-detect from scratch: the operator may have retuned before resetting.
|
||||
activeShiftHz = 0f
|
||||
detectedToneHz = null
|
||||
lastDetectAtMs = 0L
|
||||
detectFill = 0
|
||||
streamingShifter.reset()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
||||
@@ -111,7 +111,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
|
||||
// 面板与独立 CW 页各自持有自己的解码器
|
||||
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
|
||||
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
|
||||
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context) {
|
||||
// Read per chunk so toggling the setting applies without restarting capture.
|
||||
settingsRepo.otherSettings.value.cwToneShiftEnabled
|
||||
}
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ class SettingsRepo(
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keyCwToneShiftEnabled = "cwToneShiftEnabled"
|
||||
|
||||
private val separatorComma = ","
|
||||
|
||||
@@ -405,6 +406,7 @@ class SettingsRepo(
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
|
||||
|
||||
}
|
||||
new
|
||||
@@ -431,7 +433,8 @@ class SettingsRepo(
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f),
|
||||
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* The gating contract the decoder relies on: shift only when the user opted in AND the
|
||||
* tone is outside the model window.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed
|
||||
* here. What these tests do exercise is the real decision function the decoder calls -
|
||||
* [CwToneShifter.analyse] - rather than a copy of it, so a wrong verdict fails here.
|
||||
* The decoder's own sample accumulation and throttling are covered by the streaming
|
||||
* tests in core:domain.
|
||||
*/
|
||||
class CwToneShiftGateTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
private fun tone(hz: Double, samples: Int = 1600): FloatArray = FloatArray(samples) { i ->
|
||||
sin(2.0 * PI * hz * i / sampleRate).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled/disabled gate as [CwDeepDecoder.applyToneShift] applies it: when off
|
||||
* the audio is returned as-is, when on the verdict comes from the real analyser.
|
||||
*/
|
||||
private fun gate(audio: FloatArray, enabled: Boolean): FloatArray {
|
||||
if (!enabled) return audio
|
||||
val analysis = CwToneShifter.analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio
|
||||
return CwToneShifter.shift(audio, analysis.shiftHz, sampleRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled leaves every tone untouched`() {
|
||||
for (hz in listOf(150.0, 300.0, 800.0, 1200.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz must pass through unchanged while the setting is off",
|
||||
audio, gate(audio, enabled = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled still leaves in-window tones untouched`() {
|
||||
for (hz in listOf(400.0, 600.0, 800.0, 1000.0, 1200.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz is inside the window; enabling the setting must not alter it",
|
||||
audio, gate(audio, enabled = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled shifts only out-of-window tones`() {
|
||||
for (hz in listOf(200.0, 300.0, 1300.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
val result = gate(audio, enabled = true)
|
||||
assertFalse("$hz Hz should have been shifted", result === audio)
|
||||
assertEquals("shift must preserve length", audio.size, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window edges count as inside`() {
|
||||
val analysisLow = CwToneShifter.analyse(tone(CwDeepSpectrogram.MIN_FREQ_HZ), sampleRate)
|
||||
val analysisHigh = CwToneShifter.analyse(tone(CwDeepSpectrogram.MAX_FREQ_HZ), sampleRate)
|
||||
assertFalse("400 Hz is the lower edge, inside", analysisLow.needsShift)
|
||||
assertFalse("1200 Hz is the upper edge, inside", analysisHigh.needsShift)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift target is inside the window`() {
|
||||
assertTrue(
|
||||
"the target must be a pitch the model can see",
|
||||
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the defect that made the whole feature dead on arrival:
|
||||
* the decoder gated detection on a single chunk reaching DETECT_MIN_SAMPLES, but
|
||||
* AudioCapture delivers 4410 samples at 44.1 kHz, which is only 320 after
|
||||
* resampling to 3200 Hz. Detection could never run.
|
||||
*
|
||||
* The decoder now pools chunks, so what matters is that the pooled size is
|
||||
* reachable: a handful of real-sized chunks must add up to enough audio.
|
||||
*/
|
||||
@Test
|
||||
fun `pooled capture chunks reach the detection threshold`() {
|
||||
val captureRate = 44100
|
||||
val captureChunk = captureRate / 10 // AudioCapture's ~100 ms read
|
||||
val resampledChunk = captureChunk * CwDeepSpectrogram.SAMPLE_RATE / captureRate
|
||||
assertEquals(
|
||||
"a capture chunk resamples to 320 samples; if this changes revisit pooling",
|
||||
320, resampledChunk
|
||||
)
|
||||
|
||||
val threshold = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES
|
||||
val chunksNeeded = (threshold + resampledChunk - 1) / resampledChunk
|
||||
assertTrue(
|
||||
"a single chunk ($resampledChunk) must not be expected to reach $threshold",
|
||||
resampledChunk < threshold
|
||||
)
|
||||
assertTrue(
|
||||
"pooling must reach the threshold within a second of audio, needs $chunksNeeded chunks",
|
||||
chunksNeeded in 2..10
|
||||
)
|
||||
|
||||
// And that much audio must actually be enough for the detector to work.
|
||||
val pooled = tone(1500.0, samples = threshold)
|
||||
val detected = CwToneShifter.detectToneHz(pooled, sampleRate)
|
||||
assertEquals(
|
||||
"the pooled window must be long enough to detect a tone",
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,15 @@ object CwDeepSpectrogram {
|
||||
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
|
||||
const val HOP_LENGTH = 48
|
||||
|
||||
private const val MIN_FREQ_HZ = 400.0
|
||||
private const val MAX_FREQ_HZ = 1200.0
|
||||
/**
|
||||
* Lower edge of the model's analysis window. Public so [CwToneShifter] can
|
||||
* decide whether a detected tone falls outside it; the value is fixed by the
|
||||
* trained model and must not be changed without retraining.
|
||||
*/
|
||||
const val MIN_FREQ_HZ = 400.0
|
||||
|
||||
/** Upper edge of the model's analysis window; see [MIN_FREQ_HZ]. */
|
||||
const val MAX_FREQ_HZ = 1200.0
|
||||
|
||||
/** Number of frequency bins the model expects. */
|
||||
const val FREQUENCY_BINS = 65
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Moves an out-of-range CW tone into the model's analysis window.
|
||||
*
|
||||
* The DeepCW model only sees [CwDeepSpectrogram.MIN_FREQ_HZ]..[CwDeepSpectrogram.MAX_FREQ_HZ];
|
||||
* its input tensor width is fixed, so the window itself cannot be widened without
|
||||
* retraining. Instead a tone that sits outside the window is frequency-shifted to
|
||||
* [TARGET_HZ] before the spectrogram is built, which extends the usable pitch range
|
||||
* to roughly 100 Hz..Nyquist without touching the model.
|
||||
*
|
||||
* ### Why single-sideband mixing
|
||||
* Plain real mixing (`x * cos(2*pi*delta*t)`) produces both `tone+delta` and
|
||||
* `tone-delta`. Measured on a 1500 Hz tone shifted to 800 Hz, the unwanted image
|
||||
* folded back to 1000 Hz at 0.999 of the wanted amplitude — inside the window and
|
||||
* as loud as the signal. Upsampling first only moves the problem: shifting a 300 Hz
|
||||
* tone up produced a 200 Hz image at 0.996.
|
||||
*
|
||||
* A Hilbert transformer removes the negative-frequency half first, so mixing the
|
||||
* resulting analytic signal yields one sideband only. Across nine probe tones
|
||||
* (150..1550 Hz) that leaves a single spectral peak at the target with no component
|
||||
* above 0.3 relative amplitude.
|
||||
*
|
||||
* All functions are pure; the caller decides whether shifting is wanted.
|
||||
*/
|
||||
object CwToneShifter {
|
||||
|
||||
/**
|
||||
* Where an out-of-window tone is moved to: the centre of the analysis window,
|
||||
* so the keying sidebands have equal headroom on both sides.
|
||||
*/
|
||||
const val TARGET_HZ = 800.0
|
||||
|
||||
/**
|
||||
* Tones below this are treated as absent rather than shifted. Mains hum and DC
|
||||
* drift live down here, and a real CW note that low is unusable anyway.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
const val MIN_PROMINENCE = 3.0
|
||||
|
||||
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
|
||||
private const val HILBERT_TAPS = 63
|
||||
|
||||
/** Frequency resolution of [detectToneHz], in Hz. */
|
||||
private const val DETECT_STEP_HZ = 12.5
|
||||
|
||||
/** Windowed Hilbert transformer: h[n] = 2/(pi*n) for odd n, 0 otherwise. */
|
||||
private val hilbertKernel: FloatArray = FloatArray(HILBERT_TAPS) { i ->
|
||||
val n = i - HILBERT_TAPS / 2
|
||||
val ideal = if (n == 0 || n % 2 == 0) 0.0 else 2.0 / (PI * n)
|
||||
// Hamming window; without it the truncated kernel ripples badly.
|
||||
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (HILBERT_TAPS - 1))
|
||||
(ideal * window).toFloat()
|
||||
}
|
||||
|
||||
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
|
||||
private const val HILBERT_DELAY = HILBERT_TAPS / 2
|
||||
|
||||
/** Outcome of inspecting a chunk of audio. */
|
||||
data class Analysis(
|
||||
/** Detected tone in Hz, or null when the audio is noise. */
|
||||
val toneHz: Float?,
|
||||
/** True when [toneHz] sits outside the model's window and can be shifted. */
|
||||
val needsShift: Boolean,
|
||||
/** Hz the tone would be moved by; 0 when no shift applies. */
|
||||
val shiftHz: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Estimate the dominant tone by scanning [MIN_DETECTABLE_HZ]..Nyquist with a
|
||||
* Goertzel-style single-bin DFT.
|
||||
*
|
||||
* Deliberately not reusing [CwDeepSpectrogram]: that clips to the model window,
|
||||
* which is exactly the region an out-of-range tone is *not* in.
|
||||
*
|
||||
* @return the peak frequency, or null when nothing stands out from the noise.
|
||||
*/
|
||||
fun detectToneHz(audio: FloatArray, sampleRate: Int): Float? {
|
||||
if (audio.size < 64) return null
|
||||
val nyquist = sampleRate / 2.0
|
||||
// A Hann window stops the scan from smearing energy across neighbours.
|
||||
val window = FloatArray(audio.size) { i ->
|
||||
(0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))).toFloat()
|
||||
}
|
||||
|
||||
var bestHz = 0.0
|
||||
var bestMagnitude = 0.0
|
||||
var total = 0.0
|
||||
var bins = 0
|
||||
|
||||
var hz = MIN_DETECTABLE_HZ
|
||||
while (hz <= nyquist) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val value = audio[i] * window[i]
|
||||
real += value * cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
val magnitude = hypot(real, imag) / audio.size
|
||||
total += magnitude
|
||||
bins++
|
||||
if (magnitude > bestMagnitude) {
|
||||
bestMagnitude = magnitude
|
||||
bestHz = hz
|
||||
}
|
||||
hz += DETECT_STEP_HZ
|
||||
}
|
||||
|
||||
if (bins == 0 || bestMagnitude <= 0.0) return null
|
||||
val mean = total / bins
|
||||
// Pure noise has a flat spectrum, so the peak barely beats the mean.
|
||||
if (mean <= 0.0 || bestMagnitude < mean * MIN_PROMINENCE) return null
|
||||
return bestHz.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether [audio] needs shifting, without modifying it.
|
||||
*
|
||||
* A tone already inside the window is left alone: shifting it would add filter
|
||||
* ringing and rounding for no benefit, and the model handles it natively.
|
||||
*/
|
||||
fun analyse(audio: FloatArray, sampleRate: Int): Analysis {
|
||||
val tone = detectToneHz(audio, sampleRate)
|
||||
?: return Analysis(toneHz = null, needsShift = false, shiftHz = 0f)
|
||||
val inWindow = tone >= CwDeepSpectrogram.MIN_FREQ_HZ && tone <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
if (inWindow) return Analysis(toneHz = tone, needsShift = false, shiftHz = 0f)
|
||||
return Analysis(
|
||||
toneHz = tone,
|
||||
needsShift = true,
|
||||
shiftHz = (TARGET_HZ - tone).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift [audio] by [shiftHz] using single-sideband mixing.
|
||||
*
|
||||
* The Hilbert transformer suppresses the negative-frequency half, so only the
|
||||
* wanted sideband survives; see the class docs for the measured alternative.
|
||||
* Returns a new array; [audio] is not modified.
|
||||
*
|
||||
* Stateless: [audio] is treated as an isolated signal, so the first and last
|
||||
* [HILBERT_DELAY] samples convolve against zeros instead of the neighbouring
|
||||
* audio. Fine for a whole buffer, but it corrupts 62 of every 320 samples when
|
||||
* called per capture chunk, so streaming callers must use [Streaming].
|
||||
*/
|
||||
fun shift(audio: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || audio.isEmpty()) return audio
|
||||
|
||||
// Quadrature path: audio convolved with the Hilbert kernel.
|
||||
val quadrature = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = i - k + HILBERT_DELAY
|
||||
if (j >= 0 && j < audio.size) sum += hilbertKernel[k] * audio[j]
|
||||
}
|
||||
quadrature[i] = sum
|
||||
}
|
||||
|
||||
// Re{(inPhase + j*quadrature) * e^(j*2*pi*shift*t)}
|
||||
val out = FloatArray(audio.size)
|
||||
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()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk shifter that carries the state [shift] cannot.
|
||||
*
|
||||
* Two things must survive across calls for concatenated chunks to form a clean
|
||||
* signal:
|
||||
*
|
||||
* 1. **Filter history.** The Hilbert FIR spans [HILBERT_TAPS] samples, so the
|
||||
* first outputs of a chunk need the previous chunk's tail. Without it those
|
||||
* samples convolve against zeros; measured on 320-sample chunks that distorts
|
||||
* 62 of them (19%) and inflates envelope ripple to 8.7x the whole-buffer
|
||||
* baseline.
|
||||
* 2. **Mixer phase.** Restarting the local oscillator at zero every chunk puts a
|
||||
* phase step at every boundary.
|
||||
*
|
||||
* One difference from [shift] remains and is unavoidable: output sample `i` ideally
|
||||
* needs input up to `i + HILBERT_DELAY`, which for the last samples of a chunk has
|
||||
* not been captured yet. Those trailing taps therefore see zeros. Measured against
|
||||
* a whole-buffer shift the divergence is confined to the final 3 samples of each
|
||||
* 320-sample chunk and disappears immediately after the boundary — under 1% of the
|
||||
* audio, versus a 20 WPM dot spanning 192 samples. Buffering a chunk to remove it
|
||||
* would add 10 ms of latency for no decoding benefit.
|
||||
*
|
||||
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||
*/
|
||||
class Streaming {
|
||||
|
||||
private val history = FloatArray(HILBERT_TAPS - 1)
|
||||
private var phase = 0.0
|
||||
|
||||
/** Shift one chunk, continuing the filter and oscillator state. */
|
||||
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || chunk.isEmpty()) {
|
||||
// Still advance the history, so enabling a shift later starts from real
|
||||
// audio rather than the silence left over from before.
|
||||
pushHistory(chunk)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// Convolve over [history || chunk] so every output sees real samples.
|
||||
val combined = FloatArray(history.size + chunk.size)
|
||||
history.copyInto(combined)
|
||||
chunk.copyInto(combined, history.size)
|
||||
|
||||
val out = FloatArray(chunk.size)
|
||||
val step = 2.0 * PI * shiftHz / sampleRate
|
||||
for (i in chunk.indices) {
|
||||
val centre = history.size + i
|
||||
var quadrature = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = centre - k + HILBERT_DELAY
|
||||
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()
|
||||
}
|
||||
|
||||
// Keep the phase bounded; letting it grow loses float precision.
|
||||
phase = (phase + step * chunk.size) % (2.0 * PI)
|
||||
pushHistory(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear filter history and phase, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
history.fill(0f)
|
||||
phase = 0.0
|
||||
}
|
||||
|
||||
/** Keep the most recent [history] samples of the stream. */
|
||||
private fun pushHistory(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
if (chunk.size >= history.size) {
|
||||
chunk.copyInto(history, 0, chunk.size - history.size, chunk.size)
|
||||
} else {
|
||||
history.copyInto(history, 0, chunk.size, history.size)
|
||||
chunk.copyInto(history, history.size - chunk.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: analyse [audio] and shift it only when the tone is
|
||||
* outside the model window.
|
||||
*
|
||||
* @return the audio to feed the model (the original array when no shift was
|
||||
* needed) paired with the [Analysis] that produced the decision, so callers
|
||||
* can log what happened.
|
||||
*/
|
||||
fun shiftIfOutsideWindow(audio: FloatArray, sampleRate: Int): Pair<FloatArray, Analysis> {
|
||||
val analysis = analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio to analysis
|
||||
return shift(audio, analysis.shiftHz, sampleRate) to analysis
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -76,7 +76,13 @@ data class OtherSettings(
|
||||
val wavelogAutoUpload: Boolean = false,
|
||||
// Upstream radar compass offset (merged from rt-bishop)
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f
|
||||
val radarCompassOffsetElev: Float = 0f,
|
||||
/**
|
||||
* Shift a CW tone that sits outside the model's 400-1200 Hz analysis window into
|
||||
* it before decoding. Off by default: when disabled the audio path is unchanged,
|
||||
* and a tone already inside the window is never touched either way.
|
||||
*/
|
||||
val cwToneShiftEnabled: Boolean = false
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* [CwToneShifter.Streaming] exists because the decoder shifts one ~320-sample chunk at
|
||||
* a time. Shifting each chunk in isolation makes the Hilbert FIR convolve against zeros
|
||||
* at both edges, which distorted 62 of every 320 samples and inflated envelope ripple
|
||||
* to 8.7x the whole-buffer baseline. These tests fail if that state handling regresses.
|
||||
*/
|
||||
class CwToneShifterStreamingTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
/** ~100 ms of audio once resampled to 3200 Hz, matching what the decoder receives. */
|
||||
private val chunkSize = 320
|
||||
|
||||
private fun continuousTone(hz: Double, samples: Int): FloatArray =
|
||||
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
|
||||
|
||||
/** RMS envelope; a steady tone must produce a flat one. */
|
||||
private fun envelope(audio: FloatArray, window: Int = 48): List<Double> {
|
||||
val out = mutableListOf<Double>()
|
||||
var i = 0
|
||||
while (i + window <= audio.size) {
|
||||
var sum = 0.0
|
||||
for (j in i until i + window) sum += audio[j].toDouble() * audio[j]
|
||||
out += sqrt(sum / window)
|
||||
i += window / 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Coefficient of variation of the envelope, as a percentage. */
|
||||
private fun ripple(audio: FloatArray, skip: Int = 0): Double {
|
||||
val env = envelope(audio.copyOfRange(skip, audio.size))
|
||||
val mean = env.average()
|
||||
if (mean == 0.0) return 0.0
|
||||
val variance = env.sumOf { (it - mean) * (it - mean) } / env.size
|
||||
return sqrt(variance) / mean * 100.0
|
||||
}
|
||||
|
||||
private fun processInChunks(audio: FloatArray, shiftHz: Float): FloatArray {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val out = FloatArray(audio.size)
|
||||
var offset = 0
|
||||
while (offset < audio.size) {
|
||||
val end = minOf(offset + chunkSize, audio.size)
|
||||
val chunk = audio.copyOfRange(offset, end)
|
||||
shifter.process(chunk, shiftHz, sampleRate).copyInto(out, offset)
|
||||
offset = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunked streaming keeps a steady tone as flat as whole-buffer shifting`() {
|
||||
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)
|
||||
|
||||
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 must match whole-buffer shifting everywhere except the last
|
||||
* [lookahead] samples of each chunk.
|
||||
*
|
||||
* That exception is causal, not a defect: producing output sample `i` needs input
|
||||
* up to `i + HILBERT_DELAY`, which for the tail of a chunk has not been captured
|
||||
* yet. A whole-buffer call sees those samples; a live stream cannot. Measured, the
|
||||
* divergence is confined to the final 3 samples of each 320-sample chunk (under 1%
|
||||
* of the audio) and vanishes immediately after the boundary, which is why the
|
||||
* decoder accepts it rather than delaying output by 10 ms.
|
||||
*/
|
||||
@Test
|
||||
fun `chunked output matches whole-buffer output except the causal tail`() {
|
||||
val audio = continuousTone(1500.0, chunkSize * 12)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
val whole = CwToneShifter.shift(audio, shiftHz, sampleRate)
|
||||
val streamed = processInChunks(audio, shiftHz)
|
||||
|
||||
val lookahead = 32 // HILBERT_TAPS / 2, rounded up
|
||||
val skip = 128 // filter start-up transient
|
||||
var worstInterior = 0.0
|
||||
var worstTail = 0.0
|
||||
for (i in skip until audio.size) {
|
||||
val distanceToBoundary = chunkSize - (i % chunkSize)
|
||||
val delta = abs(whole[i] - streamed[i]).toDouble()
|
||||
if (distanceToBoundary <= lookahead) {
|
||||
worstTail = maxOf(worstTail, delta)
|
||||
} else {
|
||||
worstInterior = maxOf(worstInterior, delta)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"away from chunk tails the two must agree; worst divergence was " +
|
||||
"$worstInterior, so filter history or mixer phase is not being carried",
|
||||
worstInterior < 0.01
|
||||
)
|
||||
// The tail is allowed to differ, but not wildly: a broken implementation would
|
||||
// diverge by the full signal amplitude rather than a fraction of it.
|
||||
assertTrue(
|
||||
"chunk-tail divergence $worstTail exceeds the causal lookahead budget",
|
||||
worstTail < 0.5
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifted chunks land on the target frequency`() {
|
||||
val audio = continuousTone(1500.0, chunkSize * 16)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
val streamed = processInChunks(audio, shiftHz)
|
||||
|
||||
val detected = CwToneShifter.detectToneHz(streamed, sampleRate)
|
||||
assertEquals(
|
||||
"streamed audio must end up at the target pitch",
|
||||
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero shift passes chunks through untouched`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val chunk = continuousTone(800.0, chunkSize)
|
||||
assertSame(
|
||||
"a zero shift must not copy or alter the chunk",
|
||||
chunk, shifter.process(chunk, 0f, sampleRate)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history survives a run of zero-shift chunks`() {
|
||||
// Feeding audio while disabled must still fill the history, so that enabling
|
||||
// the shift mid-stream does not convolve against leftover silence.
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val audio = continuousTone(1500.0, chunkSize * 6)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
// First three chunks with no shift, then start shifting.
|
||||
var offset = 0
|
||||
repeat(3) {
|
||||
shifter.process(audio.copyOfRange(offset, offset + chunkSize), 0f, sampleRate)
|
||||
offset += chunkSize
|
||||
}
|
||||
val firstShifted = shifter.process(
|
||||
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
|
||||
)
|
||||
|
||||
// With history primed the very first shifted chunk should already be clean;
|
||||
// a cold filter would show a large amplitude dip at its start.
|
||||
val head = envelope(firstShifted.copyOfRange(0, 96)).average()
|
||||
val tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average()
|
||||
assertTrue(
|
||||
"first shifted chunk starts at $head but settles at $tail; history was not kept",
|
||||
head > tail * 0.7
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears state so the next chunk starts cold`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val audio = continuousTone(1500.0, chunkSize * 4)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
|
||||
var offset = 0
|
||||
repeat(3) {
|
||||
shifter.process(audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate)
|
||||
offset += chunkSize
|
||||
}
|
||||
shifter.reset()
|
||||
|
||||
val afterReset = shifter.process(
|
||||
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
|
||||
)
|
||||
// Cold filter: the leading samples are attenuated relative to the settled tail.
|
||||
val head = envelope(afterReset.copyOfRange(0, 64)).average()
|
||||
val tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average()
|
||||
assertTrue(
|
||||
"reset must clear history, so the head ($head) should be quieter than " +
|
||||
"the settled tail ($tail)",
|
||||
head < tail
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles chunks larger than the history window`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val big = continuousTone(1500.0, 5000)
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
val out = shifter.process(big, shiftHz, sampleRate)
|
||||
assertEquals(big.size, out.size)
|
||||
assertTrue("output must be finite", out.all { it.isFinite() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles chunks smaller than the history window`() {
|
||||
val shifter = CwToneShifter.Streaming()
|
||||
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
|
||||
// 16-sample chunks are far below the 62-sample history; the ring must still work.
|
||||
val audio = continuousTone(1500.0, 16 * 40)
|
||||
var offset = 0
|
||||
val collected = FloatArray(audio.size)
|
||||
while (offset < audio.size) {
|
||||
val chunk = audio.copyOfRange(offset, offset + 16)
|
||||
shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset)
|
||||
offset += 16
|
||||
}
|
||||
assertTrue("output must be finite", collected.all { it.isFinite() })
|
||||
val detected = CwToneShifter.detectToneHz(collected, sampleRate)
|
||||
assertEquals(
|
||||
"even tiny chunks must end up at the target pitch",
|
||||
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 40.0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The shifter exists so pitches outside the model's 400-1200 Hz window can still be
|
||||
* decoded. These tests pin the two properties that make it safe to enable:
|
||||
* in-window audio is returned untouched, and shifted audio contains one clean tone.
|
||||
*/
|
||||
class CwToneShifterTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
/** Keyed CW-like tone: a gated sine with smooth edges, plus noise. */
|
||||
private fun cwTone(hz: Double, samples: Int = 1600, noise: Double = 0.02): FloatArray {
|
||||
val random = Random(42)
|
||||
return FloatArray(samples) { i ->
|
||||
// Gate on for 60 ms, off for 30 ms, repeating - roughly 20 WPM keying.
|
||||
val cyclePos = (i % (sampleRate * 90 / 1000))
|
||||
val gate = if (cyclePos < sampleRate * 60 / 1000) 1.0 else 0.0
|
||||
val value = gate * sin(2.0 * PI * hz * i / sampleRate)
|
||||
(value + (random.nextDouble() - 0.5) * 2 * noise).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Relative magnitude at [hz] using a single-bin DFT with a Hann window. */
|
||||
private fun magnitudeAt(audio: FloatArray, hz: Double): Double {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val window = 0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))
|
||||
val value = audio[i] * window
|
||||
real += value * cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
return hypot(real, imag) / audio.size
|
||||
}
|
||||
|
||||
/** Scan 100 Hz..Nyquist and return the strongest bin plus everything above a ratio. */
|
||||
private fun peaks(audio: FloatArray, minRatio: Double = 0.3): Pair<Double, List<Double>> {
|
||||
val magnitudes = mutableListOf<Pair<Double, Double>>()
|
||||
var hz = 100.0
|
||||
while (hz <= sampleRate / 2.0) {
|
||||
magnitudes += hz to magnitudeAt(audio, hz)
|
||||
hz += 12.5
|
||||
}
|
||||
val strongest = magnitudes.maxByOrNull { it.second }!!
|
||||
val others = magnitudes
|
||||
.filter { it.first != strongest.first && it.second >= strongest.second * minRatio }
|
||||
// Collapse adjacent bins of the same lobe; only distinct tones matter.
|
||||
.filter { abs(it.first - strongest.first) > 50.0 }
|
||||
.map { it.first }
|
||||
return strongest.first to others
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detects tones across the audible range`() {
|
||||
for (tone in listOf(150.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1300.0, 1500.0)) {
|
||||
val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate)
|
||||
assertNotNull("no tone detected at $tone Hz", detected)
|
||||
assertEquals("detected pitch off at $tone Hz", tone, detected!!.toDouble(), 25.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports no tone for noise`() {
|
||||
val random = Random(7)
|
||||
val noise = FloatArray(1600) { ((random.nextDouble() - 0.5) * 2).toFloat() }
|
||||
assertNull("noise must not be mistaken for a tone", CwToneShifter.detectToneHz(noise, sampleRate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in-window tones are returned untouched`() {
|
||||
for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) {
|
||||
val audio = cwTone(tone)
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertFalse("$tone Hz is inside the window, must not shift", analysis.needsShift)
|
||||
assertEquals("no shift expected at $tone Hz", 0f, analysis.shiftHz, 0f)
|
||||
// Same instance: the caller's array must not even be copied.
|
||||
assertSame("in-window audio must be passed through", audio, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-window tones move to the target with no competing tone`() {
|
||||
for (tone in listOf(150.0, 200.0, 250.0, 300.0, 350.0, 1300.0, 1400.0, 1500.0)) {
|
||||
val audio = cwTone(tone)
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertTrue("$tone Hz is outside the window, must shift", analysis.needsShift)
|
||||
|
||||
val (strongest, competing) = peaks(result)
|
||||
assertEquals(
|
||||
"$tone Hz did not land on the target",
|
||||
CwToneShifter.TARGET_HZ, strongest, 30.0
|
||||
)
|
||||
assertTrue(
|
||||
"$tone Hz left a competing tone at $competing (single-sideband mixing failed)",
|
||||
competing.isEmpty()
|
||||
)
|
||||
assertTrue(
|
||||
"shifted tone must land inside the model window",
|
||||
CwToneShifter.isInsideWindow(strongest.toFloat())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift with zero offset returns the same array`() {
|
||||
val audio = cwTone(800.0)
|
||||
assertSame(audio, CwToneShifter.shift(audio, 0f, sampleRate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift preserves length and stays finite`() {
|
||||
val audio = cwTone(1500.0)
|
||||
val shifted = CwToneShifter.shift(audio, -700f, sampleRate)
|
||||
assertEquals("length must be preserved", audio.size, shifted.size)
|
||||
assertTrue("output must be finite", shifted.all { it.isFinite() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input is handled`() {
|
||||
val empty = FloatArray(0)
|
||||
assertSame(empty, CwToneShifter.shift(empty, -700f, sampleRate))
|
||||
assertNull(CwToneShifter.detectToneHz(empty, sampleRate))
|
||||
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(empty, sampleRate)
|
||||
assertSame(empty, result)
|
||||
assertFalse(analysis.needsShift)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifted audio survives the spectrogram with energy inside the window`() {
|
||||
// End-to-end: a 1500 Hz tone is invisible to the model, the shifted one is not.
|
||||
val audio = cwTone(1500.0, samples = 3200)
|
||||
|
||||
val rawSpectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val rawEnergy = rawSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
|
||||
|
||||
val (shifted, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
|
||||
assertTrue(analysis.needsShift)
|
||||
val shiftedSpectrogram = CwDeepSpectrogram.compute(shifted)
|
||||
val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
|
||||
|
||||
assertTrue(
|
||||
"shifting must put more energy in the model window (raw=$rawEnergy shifted=$shiftedEnergy)",
|
||||
shiftedEnergy > rawEnergy * 1.5
|
||||
)
|
||||
assertEquals(
|
||||
"bin count must stay compatible with the model",
|
||||
CwDeepSpectrogram.FREQUENCY_BINS, shiftedSpectrogram[0].size
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,4 +169,6 @@
|
||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Desplazar tonos CW fuera de rango</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica.</string>
|
||||
</resources>
|
||||
@@ -298,4 +298,6 @@
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
|
||||
</resources>
|
||||
@@ -298,4 +298,6 @@
|
||||
\n* BA7OPF (fitur pencocokan lintasan)
|
||||
\n* BG7NTA</string>
|
||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
|
||||
</resources>
|
||||
@@ -169,4 +169,6 @@
|
||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Сдвигать CW-тоны вне диапазона</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
|
||||
</resources>
|
||||
@@ -169,4 +169,6 @@
|
||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">පරාසයෙන් පිටත CW ස්වර මාරු කරන්න</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
|
||||
</resources>
|
||||
@@ -310,4 +310,6 @@
|
||||
<string name="radar_cw_tone">Ton %1$d Hz</string>
|
||||
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Aralık dışı CW tonlarını kaydır</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez.</string>
|
||||
</resources>
|
||||
@@ -169,4 +169,6 @@
|
||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||
|
||||
<string name="prefs_other_switch_cw_tone_shift">Зсувати CW-тони поза діапазоном</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
|
||||
</resources>
|
||||
@@ -300,4 +300,6 @@
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
|
||||
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
|
||||
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">搬移超出范围的 CW 音调</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
|
||||
</resources>
|
||||
@@ -333,4 +333,6 @@
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||
<string name="prefs_other_switch_cw_tone_shift">Shift out-of-range CW tones</string>
|
||||
<string name="prefs_other_cw_tone_shift_help">DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched.</string>
|
||||
</resources>
|
||||
+10
@@ -636,6 +636,16 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// CW decoding: shift out-of-window tones into the model's 400-1200 Hz window
|
||||
SwitchRow(R.string.prefs_other_switch_cw_tone_shift, settings.cwToneShiftEnabled) {
|
||||
onAction(SettingsAction.ToggleCwToneShift(it))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_other_cw_tone_shift_help),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Compass calibration sliders at the bottom
|
||||
CompassOffsetRow(
|
||||
labelResId = R.string.prefs_other_compass_offset,
|
||||
|
||||
@@ -61,6 +61,9 @@ sealed interface SettingsAction {
|
||||
// Toggles
|
||||
data class ToggleUtc(val value: Boolean) : SettingsAction
|
||||
data class ToggleUpdate(val value: Boolean) : SettingsAction
|
||||
|
||||
/** Shift CW tones outside the model's 400-1200 Hz window into it before decoding. */
|
||||
data class ToggleCwToneShift(val value: Boolean) : SettingsAction
|
||||
data class ToggleSweep(val value: Boolean) : SettingsAction
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
|
||||
+1
@@ -127,6 +127,7 @@ class SettingsViewModel(
|
||||
// Toggles
|
||||
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
|
||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||
is SettingsAction.ToggleCwToneShift -> settingsRepo.updateOtherSettings { it.copy(cwToneShiftEnabled = action.value) }
|
||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
|
||||
Reference in new issue
Block a user