Compare commits

...
4 Commits
Author SHA1 Message Date
mckero c42e1d7b4e fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT"
Satellite QSOs uploaded with BAND=SAT, which is not a legal ADIF Band
enumeration value (the legal values are concrete bands: 160M/80M/.../
2M/70CM/23CM...). Loggers that fail to parse an unknown band fall back
to a default — observed as QSOs landing in 160m. SAT is only legal as
PROP_MODE (propagation mode), which is already sent for v1.

Changes (WaveLogApi):
- bandFromHz(): map TX frequency to the real ADIF band (2M for VHF,
  70CM for UHF, etc.)
- satModeFrom(): derive the ADIF SAT_MODE convention string from TX/RX
  bands ("V/U" = VHF up / UHF down, "U/V", "V/S", "U/S"...; empty for
  same-band links)
- v2 JSON: band=<real band>, add sat_mode when non-empty
- v1 ADIF: <band:> real band, add <sat_mode:> when non-empty;
  PROP_MODE=SAT kept

Verification:
- New tests: SO-50 (145.850 up / 436.795 down) -> band 2M, sat_mode V/U;
  AO-73 (435.150 up / 145.950 down) -> band 70CM, sat_mode U/V;
  same-band -> empty sat_mode; satellite freqs never map to 160M.
- All wavelog payload tests + full domain suite green.
2026-08-09 07:48:15 +00:00
mckero 09ebf1f39a feat(cw): add spectral auto-tune so the decoder finds the CW tone
The fldigi port ran a fixed 600 Hz NCO, so any real signal not inside
600±75 Hz (the 150 Hz filter passband) decoded nothing — the decode rate
was effectively zero unless the tone happened to be on frequency. This
mirrors the behaviour of the removed channelTracker: a sliding spectral
peak detector now steers the NCO to the strongest tone.

Changes:
- Collect raw input, run a 512-pt Hann-windowed FFT every frame, find
  the strongest bin in 300..1500 Hz (CW range), smooth-track it.
- First strong peak locks immediately (no RX reset, so the triggering
  element survives); later large jumps (>120 Hz) retune and reset the
  fldigi state machine; small drifts are eased at 20%.
- Absolute energy floor (peak < 30) so silence/noise never steers.
- estimatedPitch now reflects the tracked tone frequency.

Verification:
- New unit test: 900 Hz "CQ" with decoder initialized at 600 Hz decodes
  correctly and pitch moves to ~900 Hz.
- All 9 decoder tests pass; full domain/cw/radar test suites green.
2026-08-09 05:04:48 +00:00
mckero ec40f29f28 fix(cw): make the waterfall redraw as new spectra arrive
The waterfall backed its pixels with a plain FloatArray and never
signalled Compose, so the Canvas drew once (empty) and stayed frozen —
no spectrum ever appeared. Add a monotonic frame-counter State that
pushSamples bumps per FFT frame; the Canvas reads it in composition to
trigger redraws. Also switch to log-ish intensity scaling so quiet bins
stay dark while strong CW tones pop, matching the DeepCW look.

Applies to both the CW decode screen and the radar transceiver panel.
2026-08-08 15:04:12 +00:00
mckero 5dd7a35a23 fix(cw): remove unused legacy drawables that fail release resource linking
ic_baseline_delete/pause/save/share_24.xml were leftovers from the
Morse Expert View-based UI. They reference ?attr/colorControlNormal
which does not resolve in the release variant (no Material dependency
in feature:cw), breaking assembleRelease. The new Compose UI uses
icons from core:presentation, so these files are dead code.
2026-08-08 14:45:10 +00:00
10 changed files with 302 additions and 53 deletions

No files matched your search

@@ -47,6 +47,12 @@ class CwFldigiDecoder(
const val CW_QUERY = 3
const val CW_SUCCESS = 0
const val CW_ERROR = -1
// Auto-tune (spectral peak tracking, mirrors the old channelTracker)
const val TUNE_FFT_SIZE = 512
const val TUNE_MIN_FREQ = 300.0
const val TUNE_MAX_FREQ = 1500.0
const val TUNE_ENERGY_FRACTION = 0.35
}
private enum class CwRxState { IDLE, IN_TONE, AFTER_TONE }
@@ -64,6 +70,16 @@ class CwFldigiDecoder(
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
// --- auto-tune state (spectral peak tracking) ---
private var tuneFreq = frequency
private val tuneBuffer = DoubleArray(TUNE_FFT_SIZE)
private var tuneIdx = 0
private var tuneFft = CwGfft(TUNE_FFT_SIZE)
private var tunePeakFreq = frequency
private var tuneHasPeak = false
private var tuneLocked = false
private val tuneWindow = DoubleArray(TUNE_FFT_SIZE)
// --- fldigi cw state ---
private var phaseacc = 0.0
private var FFTphase = 0.0
@@ -128,17 +144,99 @@ class CwFldigiDecoder(
/** Main entry: feed PCM samples. */
fun processBuffer(buffer: FloatArray) {
for (sample in buffer) {
feedTuner(sample.toDouble())
rxSample(sample.toDouble())
}
}
/**
* Spectral peak tracking (auto-tune). Collects raw samples, runs an FFT
* every TUNE_FFT_SIZE samples, finds the strongest peak in the CW range
* and smoothly steers tuneFreq toward it — mirroring the old
* channelTracker behaviour so off-tune signals still decode.
*/
private fun feedTuner(sample: Double) {
tuneBuffer[tuneIdx++] = sample
if (tuneIdx < TUNE_FFT_SIZE) return
tuneIdx = 0
// Hann window
for (i in 0 until TUNE_FFT_SIZE) {
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (TUNE_FFT_SIZE - 1))
tuneWindow[i] = tuneBuffer[i] * w
}
val data = Array(TUNE_FFT_SIZE) { CwComplex(tuneWindow[it], 0.0) }
tuneFft.forward(data)
// Find the strongest bin in the CW range (300..1500 Hz)
val binMin = (TUNE_MIN_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
val binMax = (TUNE_MAX_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
var bestBin = -1
var bestMag = 0.0
var totalMag = 0.0
for (b in binMin..binMax) {
val mag = data[b].abs()
totalMag += mag
if (mag > bestMag) {
bestMag = mag
bestBin = b
}
}
if (bestBin < 0) return
val meanMag = totalMag / (binMax - binMin + 1)
if (bestMag < meanMag * 2.0) return // no clear tone
// Absolute floor: silence or weak noise must not steer the NCO.
// A 0.6-amplitude tone in a 512-pt Hann FFT yields peak ≈ 150;
// anything below ~30 is noise/DC leakage.
if (bestMag < 30.0) return
val peakFreq = bestBin * sampleRate.toDouble() / TUNE_FFT_SIZE
tunePeakFreq = peakFreq
tuneHasPeak = true
_estimatedPitch.value = peakFreq.toFloat()
// Lock fast: a strong peak on the very first frame is reliable enough
// (CW tones are narrow and dominate the band). Steer immediately so
// the first character still decodes. Do NOT reset the RX state on the
// first lock — the AGC adapts in a few frames and a reset would wipe
// the element that triggered the tune.
if (!tuneLocked) {
tuneFreq = peakFreq
tuneLocked = true
_estimatedPitch.value = peakFreq.toFloat()
} else {
// Smooth tracking; retune instantly on big jumps (signal switched freq)
val diff = peakFreq - tuneFreq
if (kotlin.math.abs(diff) > 120.0) {
tuneFreq = peakFreq
resetRxState()
} else {
tuneFreq += diff * 0.2
}
}
}
/** Reset only the fldigi RX state machine (keep decoded text). */
private fun resetRxState() {
cw_receive_state = CwRxState.IDLE
old_cw_receive_state = CwRxState.IDLE
smpl_ctr = 0
cw_ptr = 0
rx_rep_buf.clear()
last_element = 0
space_sent = true
FFTphase = 0.0
phaseacc = 0.0
}
private fun rxSample(value: Double) {
// NCO down-conversion (fldigi rx_FFTprocess)
// NCO down-conversion (fldigi rx_FFTprocess). tuneFreq tracks the
// strongest spectral peak so off-tune signals still decode.
val z = CwComplex(
value * kotlin.math.cos(FFTphase),
value * kotlin.math.sin(FFTphase)
)
FFTphase += 2.0 * Math.PI * frequency / sampleRate
FFTphase += 2.0 * Math.PI * tuneFreq / sampleRate
if (FFTphase > 2.0 * Math.PI) FFTphase -= 2.0 * Math.PI
val out = cw_FFT_filter.run(z) ?: return
@@ -422,5 +520,11 @@ class CwFldigiDecoder(
_estimatedSpeed.value = null
FFTphase = 0.0
phaseacc = 0.0
// reset auto-tune
tuneIdx = 0
tunePeakFreq = frequency
tuneHasPeak = false
tuneLocked = false
tuneFreq = frequency
}
}
@@ -106,6 +106,51 @@ object WaveLogApi {
WavelogResult.Success("")
}
/**
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
* that fails to parse an illegal band falls back to a default such as
* 160m. Satellite QSOs must carry the real band of the TX frequency.
*/
fun bandFromHz(freqHz: Long): String = when {
freqHz >= 1240_000_000 -> "23CM"
freqHz >= 902_000_000 -> "33CM"
freqHz >= 420_000_000 -> "70CM"
freqHz >= 222_000_000 -> "1.25M"
freqHz >= 144_000_000 -> "2M"
freqHz >= 50_000_000 -> "6M"
freqHz >= 28_000_000 -> "10M"
freqHz >= 24_890_000 -> "12M"
freqHz >= 21_000_000 -> "15M"
freqHz >= 18_068_000 -> "17M"
freqHz >= 14_000_000 -> "20M"
freqHz >= 10_000_000 -> "30M"
freqHz >= 7_000_000 -> "40M"
freqHz >= 5_102_000 -> "60M"
freqHz >= 3_500_000 -> "80M"
freqHz >= 1_800_000 -> "160M"
else -> "160M"
}
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
private fun bandLetter(freqHz: Long): String = when {
freqHz >= 1_240_000_000 -> "S"
freqHz >= 420_000_000 -> "U"
freqHz >= 144_000_000 -> "V"
else -> "V"
}
/**
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
*/
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
if (rxFreqHz <= 0) return ""
val up = bandLetter(txFreqHz)
val down = bandLetter(rxFreqHz)
return if (up == down) "" else "$up/$down"
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
@@ -142,10 +187,11 @@ object WaveLogApi {
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
@@ -155,6 +201,7 @@ object WaveLogApi {
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
@@ -183,9 +230,10 @@ object WaveLogApi {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
return buildString {
append(field("call", qso.call))
append(field("band", "SAT"))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
@@ -198,6 +246,7 @@ object WaveLogApi {
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
append(field("prop_mode", "SAT"))
}
append("<eor>")
@@ -146,4 +146,35 @@ class CwFldigiDecoderTest {
val maxVal = out.maxOrNull() ?: 0.0
assertTrue("expected signal to pass through lowpass, max=$maxVal", maxVal > 0.05)
}
@Test
fun autoTune_decodesOffFreqSignal() {
// Signal at 900 Hz but decoder initialized at 600 Hz: auto-tune must
// steer the NCO to the real tone, otherwise nothing decodes.
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = 600.0)
val dotLen = CwFldigiConstants.KWPM / 18
val synth = { ch: Char ->
val len = if (ch == '.') dotLen else 3 * dotLen
FloatArray(len) { i ->
val t = i.toDouble() / sampleRate
(0.6 * sin(2.0 * PI * 900.0 * t)).toFloat()
}
}
// "CQ" at 900 Hz: -.-. --.-
val text = StringBuilder()
val seq = listOf(listOf('-', '.', '-', '.'), listOf('-', '-', '.', '-'))
for ((ci, word) in seq.withIndex()) {
for ((i, ch) in word.withIndex()) {
decoder.processBuffer(synth(ch))
if (i < word.size - 1) decoder.processBuffer(FloatArray(dotLen))
}
if (ci < seq.size - 1) decoder.processBuffer(FloatArray(3 * dotLen))
}
decoder.processBuffer(FloatArray(6 * dotLen))
val result = decoder.decodedTextFlow.value
assertTrue("expected CQ from 900 Hz signal (auto-tune), got: $result", result.contains("C") && result.contains("Q"))
// pitch should have moved toward 900 Hz
val pitch = decoder.estimatedPitch.value ?: 0f
assertTrue("expected pitch near 900, got $pitch", pitch > 800f && pitch < 1000f)
}
}
@@ -0,0 +1,92 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* Verifies the WaveLog upload payload frequency/band fields.
*
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
* value corrupts band derivation.
*/
class WaveLogApiPayloadTest {
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
private val uplinkHz = 145_850_000L
private val downlinkHz = 436_795_000L
@Test
fun v2_freq_usesMhzStringWithMSuffix() {
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
assertEquals("145.850000M", freq)
assertEquals("436.795000M", freqRx)
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
val parsedHz = parseLikeWaveLog(freq)
assertEquals(uplinkHz, parsedHz)
}
@Test
fun v1_adif_freq_isBareMhzNumber() {
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
assertEquals("145.850000", freq)
val adifFreq = freq.toDouble() * 1_000_000
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
}
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
private fun parseLikeWaveLog(raw: String): Long {
val s = raw.trim()
return if (s.endsWith("M", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000_000).toLong()
} else if (s.endsWith("k", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000).toLong()
} else {
s.toLong()
}
}
@Test
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
// Doppler-corrected values must remain near the base frequency.
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
val dopplerRate = 7.0 // km/s approaching
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
assertTrue(
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
kotlin.math.abs(corrected - uplinkHz) < 20_000
)
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
val mhz = corrected / 1_000_000.0
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
}
@Test
fun band_isRealBand_notSAT() {
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
// Same-band (e.g. simplex) -> empty sat mode
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
// Never 160m for satellite frequencies
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
}
@Test
fun adif_containsRealBandAndSatMode() {
// v2 payload fields (mirror postQso construction)
val band = WaveLogApi.bandFromHz(uplinkHz)
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
assertEquals("2M", band)
assertEquals("V/U", satMode)
}
}
@@ -368,12 +368,15 @@ class CwWaterfallState {
private var colCount = 0
private val pending = ArrayList<Float>(4096)
private val fftWindow = 512
private var acc = 0f
private var accN = 0
/** Monotonic frame counter — reading this in composition triggers redraw. */
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
val version: androidx.compose.runtime.State<Int> = _version
@Synchronized
fun pushSamples(samples: FloatArray) {
pending.addAll(samples.toList())
var frames = 0
while (pending.size >= fftWindow) {
val window = FloatArray(fftWindow) { pending[it] }
repeat(fftWindow) { pending.removeAt(0) }
@@ -387,7 +390,9 @@ class CwWaterfallState {
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
}
if (colCount < rows) colCount++
frames++
}
if (frames > 0) _version.intValue++
}
/** Magnitude spectrum via radix-2 FFT on a Hann-windowed frame. */
@@ -466,6 +471,8 @@ class CwWaterfallState {
private fun CwWaterfallView(state: CwWaterfallState) {
val columns = 160
val rows = 64
// Read the frame counter so the canvas redraws as new spectra arrive.
state.version.value
Canvas(
modifier = Modifier
.fillMaxSize()
@@ -478,9 +485,10 @@ private fun CwWaterfallView(state: CwWaterfallState) {
val col = state.getColumn(c)
for (r in 0 until rows) {
val v = col[r]
val intensity = (v * 4000f).coerceIn(0f, 255f)
if (intensity > 6f) {
val green = (intensity * 1.3f).coerceAtMost(255f)
// log-ish scaling: quiet bins stay dark, strong CW tones pop
val intensity = (kotlin.math.ln1p(v * 600f) * 55f).coerceIn(0f, 255f)
if (intensity > 4f) {
val green = (intensity * 1.25f).coerceAtMost(255f)
drawRect(
color = Color(0f, green / 255f, 0f, 1f),
topLeft = Offset(c * cellW, r * cellH),
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorControlNormal"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorControlNormal"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorControlNormal"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorControlNormal"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
</vector>
@@ -906,9 +906,14 @@ private class CwPanelWaterfallState {
private val pending = ArrayList<Float>(4096)
private val fftWindow = 512
/** Monotonic frame counter — reading this in composition triggers redraw. */
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
val version: androidx.compose.runtime.State<Int> = _version
@Synchronized
fun pushSamples(samples: FloatArray) {
pending.addAll(samples.toList())
var frames = 0
while (pending.size >= fftWindow) {
val window = FloatArray(fftWindow) { pending[it] }
repeat(fftWindow) { pending.removeAt(0) }
@@ -920,7 +925,9 @@ private class CwPanelWaterfallState {
val bin = c * spectrum.size / columns
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
}
frames++
}
if (frames > 0) _version.intValue++
}
private fun computeSpectrum(window: FloatArray): FloatArray {
@@ -995,6 +1002,8 @@ private class CwPanelWaterfallState {
private fun CwPanelWaterfallView(state: CwPanelWaterfallState) {
val columns = 120
val rows = 48
// Read the frame counter so the canvas redraws as new spectra arrive.
state.version.value
Canvas(
modifier = Modifier
.fillMaxSize()