diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt
index 2788f133..2f46ac12 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/cw/CwDeepDecoder.kt
@@ -25,6 +25,7 @@ import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
+import com.rtbishop.look4sat.core.domain.cw.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 = 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() {
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
index e610b5bb..787192a8 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
@@ -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)
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
index 4f02b3de..10efa69f 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
@@ -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
diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt
new file mode 100644
index 00000000..d186de2a
--- /dev/null
+++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/cw/CwToneShiftGateTest.kt
@@ -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
+ )
+ }
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt
index 9610b6ed..4d308566 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt
@@ -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
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt
new file mode 100644
index 00000000..28cefb31
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt
@@ -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 .
+ */
+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 {
+ 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
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index 3dcc4575..a3cf10fa 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -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(
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt
new file mode 100644
index 00000000..b7a7e183
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt
@@ -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 {
+ val out = mutableListOf()
+ 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
+ )
+ }
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt
new file mode 100644
index 00000000..feecb509
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt
@@ -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> {
+ val magnitudes = mutableListOf>()
+ 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
+ )
+ }
+}
diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml
index 095ed335..63b77b52 100644
--- a/core/presentation/src/main/res/values-es/strings.xml
+++ b/core/presentation/src/main/res/values-es/strings.xml
@@ -169,4 +169,6 @@
Me gustaría dar las gracias a:
La app viene sin garantías de ningún tipo.
+ Desplazar tonos CW fuera de rango
+ 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.
diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml
index 59567902..935f221f 100644
--- a/core/presentation/src/main/res/values-id/strings.xml
+++ b/core/presentation/src/main/res/values-id/strings.xml
@@ -298,4 +298,6 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA
Aplikasi ini hadir tanpa jaminan
+ Geser nada CW di luar rentang
+ 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.
diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml
index f8b25b13..ee57779a 100644
--- a/core/presentation/src/main/res/values-in/strings.xml
+++ b/core/presentation/src/main/res/values-in/strings.xml
@@ -298,4 +298,6 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA
Aplikasi ini hadir tanpa jaminan
+ Geser nada CW di luar rentang
+ 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.
diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml
index 4c73ec04..6bc0f3dc 100644
--- a/core/presentation/src/main/res/values-ru/strings.xml
+++ b/core/presentation/src/main/res/values-ru/strings.xml
@@ -169,4 +169,6 @@
Я хотел бы сказать спасибо:
Это ПО поставляется без гарантий.
+ Сдвигать CW-тоны вне диапазона
+ DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.
diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml
index 32af6348..66d0ad1a 100644
--- a/core/presentation/src/main/res/values-si/strings.xml
+++ b/core/presentation/src/main/res/values-si/strings.xml
@@ -169,4 +169,6 @@
මම ස්තුති කිරීමට කැමති:
මෘදුකාංගය වගකීමක් සමග නොලැබේ
+ පරාසයෙන් පිටත CW ස්වර මාරු කරන්න
+ DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index 10263bf2..a07e2ae4 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -310,4 +310,6 @@
Ton %1$d Hz
Sinyal bekleniyor…
+ Aralık dışı CW tonlarını kaydır
+ 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.
diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml
index 8c1b66bf..2e5810fe 100644
--- a/core/presentation/src/main/res/values-uk/strings.xml
+++ b/core/presentation/src/main/res/values-uk/strings.xml
@@ -169,4 +169,6 @@
Я хотів би подякувати:
Ця програма поставляється без жодних гарантій
+ Зсувати CW-тони поза діапазоном
+ DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 0d416694..366a0ad1 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -300,4 +300,6 @@
范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。
雷达罗盘偏移
雷达罗盘偏移 (仰角)
+ 搬移超出范围的 CW 音调
+ DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 24b2b0ce..84a374a2 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -333,4 +333,6 @@
Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.
Radar compass offset
Radar compass offset (elev)
+ Shift out-of-range CW tones
+ 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.
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index e9aef985..fb898102 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -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,
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
index c2629bcf..5c9d6c23 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
@@ -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
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
index 54315dbc..27903e4b 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
@@ -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) }