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) }